Commit Graph
472 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 01f4937dac Layering: the render-based rotated-field test moves from forms to engine
forms (L3) gained a dev-dependency on render (L3) with #516, which
`xtask layers` refuses, so main CI failed. The one test that renders a
rotated field now lives in engine's tests, which may depend on both;
the parity evidence points there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 14:08:46 -04:00
f1af1e2e79 Issue #125: page text the standard fonts can't draw is refused, not written as ? (#510)
Add text, headers/footers and watermarks wrote every character outside
WinAnsiEncoding as `?` and reported success. They now refuse it with a
specific error ("the standard fonts can't draw "中" (U+4E2D) ...") before
changing anything, as the maintainer asked on #327. Form fields and
text-box comments are unchanged (they keep the value).

- fonts: win_ansi_encodable / first_non_win_ansi, defined through
  win_ansi itself so a fuller table widens them too; the edited-text
  Type 3 decision uses the same check (same character set as before).
- edit: one drawable() check, fed the lines exactly as each tool draws
  them, so a CR that would become `?` is refused and CRLF in headers is not.
- ui: the Add-text editor keeps such text open with an inline warning.
  Done, a click elsewhere or a double-click on another item don't take
  or replace it; it counts as unsaved work; Save stops and shows it; a
  deleted page takes its draft with it.
- Arabic (#403): one public check, first_undrawable, follows the drawing:
  Arabic shaped with the craft-fonts face is drawable, and only what's left
  to the standard font counts. Without the face, #403 still decides for the
  Arabic letters (new items refused with what's missing, existing ones stay
  movable), but nothing else in the item may become `?`. The editor uses
  the same check, cached per draft.
- #403's odd_arabic_text_never_panics: its U+2029/U+0085 input, which was
  drawn as `?`, now expects a refusal naming the character.
- The warning is translated (fr, ru, zh-hans, zh-hant, es, ja, bg, de); the
  catalogs' SHA-256 entries in ATTRIBUTION.toml are updated.


Claude-Session: https://claude.ai/code/session_01VNJqS2dEQawHJn1qTRJiKp

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 13:20:47 -04:00
Márk BartalosandBrandon Thomas 3af93c7585 M5: draw all ten PDF line-ending styles (#520)
* M5: draw all ten PDF line-ending styles

Imported lines, polylines and callouts kept Square, Circle, Diamond, Butt, Slash and reverse arrows in /LE but would not redraw them. Those styles now draw, and comment_add and Comment properties can set them.

* hu, uk: Line ending

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 13:15:44 -04:00
Márk BartalosandBrandon Thomas c9d0619226 Forms: rotate field widgets with /MK /R (#516)
* M6: rotate form field widgets with /MK /R

Filling or restyling a rotated field redrew its text upright. Appearances now follow /MK /R, and Field Properties and form_set_props can set the rotation.

* Forms: a rotation refused as too small is checked before any other property is written; no indexing by widget number

* hu, uk: Rotation:

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 13:13:12 -04:00
2d24de1c16 M7: edit text: Cyrillic/Greek fallback letters are spaced proportionally (#528)
Replacement text that the line's own (subset) font can't show and WinAnsi can't either goes
to the Type 3 fallback. For serif sources that was always Shippori Mincho, which has no
Cyrillic or Greek, so e.g. retyping a Bulgarian word in a Garamond line failed with
"Japanese fallback font has no glyph for U+0421". BIZ UDMincho and BIZ UDPGothic have the
letters. document_font_for_text keeps the style's face when it shows every character and
otherwise picks the closest Japanese face that does (Mincho first for serif, then weight).

Those faces draw Cyrillic and Greek full-width (1 em, JIS X 0208) with no palt metrics, so
fallback glyphs in those blocks are respaced to their ink width plus a 0.05 em side bearing.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 13:06:33 -04:00
0375bd1e28 M6.2: form fields in Unicode CID fonts show Japanese instead of "?" (#535)
* M6.2: form fields in Unicode CID fonts show Japanese instead of "?"

Japanese forms often give their fields a /DA font that /DR defines as a Type0
font with a predefined Unicode CMap, such as a non-embedded HeiseiMin-W3 with
/UniJIS-UCS2-H. Field appearances accepted only simple fonts, so they fell back
to Helvetica and WinAnsi, and every Japanese character was drawn as "?".

Appearances now use such fonts and encode the text in the CMap's form (UCS-2,
UTF-16, UTF-8 or UTF-32), measuring full-width characters as one em so centring,
auto size, comb cells and multiline wrapping fit. Identity-H and embedded CMaps
still fall back to Helvetica.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* M6.2: vertical Unicode CMaps fall back to Helvetica, since field appearances are horizontal

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 12:59:33 -04:00
f78f74164c M6.2: check boxes with non-UTF-8 state names show their mark when clicked (#536)
Japanese forms often name a check box's on state in Shift-JIS (「はい」 is
/#82#CD#82#A2). State names were read with from_utf8_lossy, so ticking such a
box wrote a different name to /AS and /V, no appearance matched, and the box
was drawn with no mark (or not at all).

Names now map to text losslessly (forms::name_text / name_bytes: UTF-8 as
itself, other bytes and `#` as #XX) wherever states and values are read and
written: /V, /DV and /AS, PdfCraft's own state appearances, and FDF import and
export, so such values also survive an FDF or XFDF round trip.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 12:58:38 -04:00
e6df6b1384 i18n: add complete Ukrainian interface translation (#509)
* M14: add complete Ukrainian interface translation

Add the uk registry entry and original catalog, Ukrainian integer plurals,
calendar month inflection, attribution and localization documentation.
Fit Home recommendation cards and the search hint to translated text.
Cover locale detection, persistence, catalogs, bundled glyphs and UI control.

Fixes #503
Related to #348

Co-authored-by: Codex <[email protected]>

* uk: add the recent-files labels, labels that reach tl!() through a variable, and 'Inserted text'; fix 'мають мати'

* uk: Home / End are key-cap names

---------

Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 12:55:46 -04:00
f626db25cc M14: refine German labels and prevent translated UI text overlap (#515)
* M14: refine German labels and keep translated text within UI bounds

Co-authored-by: Codex <[email protected]>

* Tool names inside notices stay lowercase in English; German keeps its capitalised nouns

---------

Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 12:39:57 -04:00
63363ec438 M3: rasters reach egui without a copy on the UI thread (#555)
* M3: rasters reach egui without a copy on the UI thread

Every page, tile and thumbnail raster was copied pixel by pixel on the UI
thread (`ColorImage::from_rgba_premultiplied`), because egui's `Color32` is
4-byte aligned and a `Vec<u8>` isn't. That is 0.5 ms per 1024² tile, 5.4 ms
for a 33 MB page and 36 ms for a 197 MB page, against a 16.7 ms frame.

hayro, vendored patch 7: `render_size` and `render_into` render into a buffer
the caller allocates. `render` is `render_into` on a new pixmap, so its output
is unchanged. pdfcraft-render renders straight into `Pixels`, the same
premultiplied RGBA bytes kept in one 4-byte word per pixel. `Pixels`
dereferences to `[u8]`, so every reader of `RenderedPage::rgba` compiles
unchanged. The canvas reinterprets the words as `Color32`s and hands them to
egui as they are.

Pixels are identical: all 33 renders of #260's test file hash the same. The
UI-thread copy is gone, and rendering a 6182×8000 page takes 67 ms instead of
71–76 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* M3: render_into leaves a buffer of the wrong length untouched instead of reaching vello's assert

* M3: regression test for render_into with a wrong-length buffer

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 12:28:46 -04:00
eb00280eba M1: stream data read from a file points into the file instead of being copied (#552)
* M1: stream data read from a file points into the file instead of being copied

Every stream COS loaded was copied out of the document's bytes
(`data[start..end].to_vec()`) and kept in the object cache until the document
closed. Anything that reads every object therefore left a second copy of all
stream data behind. That includes a full save, which edits needing a rewrite
(repaired files, security changes, redaction) run on every change. A full save
of a 208 MB document left 208 MB cached on it.

`Stream::raw` is now `Bytes`: owned bytes, or a range of a larger shared
buffer. Streams parsed from a document's own bytes are ranges of that buffer,
which the document keeps anyway. Decrypted and newly built streams own their
bytes as before. A stream added to a document from another document's file gets
its own copy of just its bytes (`Document::set`/`add`), so it never keeps
another whole file alive.

Results, measured with an exact heap counter:
- A full save of a 208 MB document leaves 0.2 MB cached instead of 208.2 MB.
- The first full save of a freshly opened document is about twice as fast:
  10 → 5 ms at 40 MB, 38 → 21 ms at 200 MB.

Output is identical: 68 incremental and full saves of five originals,
encrypted ones included, hash the same before and after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* M1: the full-save capacity test (#523) builds its stream from Bytes

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 12:28:33 -04:00
Márk BartalosandBrandon Thomas 95120862fc M14: Hungarian interface catalog (hu) (#545)
* M14: Hungarian interface catalog (hu)

Magyar joins the interface languages as a complete catalog, so Preferences and hu locales show commands, dialogs, panels and notices in Hungarian. Wording uses informal "te" and nouns for commands; the strings were written from the meaning of the English labels.

* Small grammar correction

* hu: restore trailing spaces in run-on labels; add the recent-files labels and labels that reach tl!() through a variable

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 12:28:06 -04:00
9ed70e185c de: translate labels that reach tl!() through variables; fix name collisions and wrong meanings (#541)
* de: translate labels that reach tl!() through variables; fix name collisions and wrong meanings

- 32 labels the German catalog was missing because they are looked up
  through a variable (Fill & Sign buttons, link prompts, notices, history
  labels, checker and certificate rows): the UI showed mixed text such as
  "Add Unterschrift" or "A link in diesem Dokument ..."
- Keep the trailing spaces of the digital-ID list parts ("Ausgestellt
  von:Example CA, läuft ab:2031-..." ran together)
- Name collisions: Add a text box / Add a text field, Paste pages / Insert pages
- Wrong meanings: Social Security Numbers (US format only), Clear form
  (resets to defaults), radio-button options called "Schaltfläche", the
  Mark as Confidential description, the first measuring point, Mouse Enter/Exit
- Small grammar fixes; du style and "das PDF" unchanged

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* de: drop a leftover merge marker

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 12:23:15 -04:00
cyfung1031andClaude Opus 5.5 24ba8385f8 Issue #324: on Windows 11, a window dragged to another monitor takes its scale (#544)
* Issue #324: vendor winit 0.30.13 as published

The crate as released on crates.io, without its asset files (docs/res/*.svg and
examples/data/*.png, AGENTS.md §1). Not used yet: the next commit patches it and
adds it to [patch.crates-io].

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Issue #324: on Windows 11, a window dragged to another monitor takes its scale

winit 0.30.13 handles WM_DPICHANGED by checking that the new window rect is on
the monitor `MonitorFromWindow` names. During a drag that is still the monitor
being left, so winit nudged the window back onto it, which sent another
WM_DPICHANGED: the window grew and stayed on the wrong monitor at the wrong
scale. From Windows 11 (build 22000) on, apply the rect Windows suggests, as
winit master does since 488c036a (rust-windowing/winit#4341); no 0.30 release
has it. Windows 10 keeps the old adjustment, as on winit master.

The vendored copy goes in [patch.crates-io]; a test fails if winit resolves
from crates.io again or the vendored copy loses the patch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 12:19:41 -04:00
martinjohuberandClaude Opus 5.5 d9047d4d5b Forms: radio groups and check boxes accept their /Opt export values (#532)
pdf-lib and other writers give radio groups and check boxes an /Opt array
(PDF 1.4, one export value per widget) and name the on states by position
(/0, /1 ...). Filling such a field by its export value ("Pick-up") failed with
"has no option", and form_fields listed the options as 0, 1.

- Field::export_of / state_for / export_for_state map between a widget, its
  /Opt export value and its on-state name; fields without /Opt are unchanged
- set_value accepts an export value for radio groups and check boxes; the
  "has no option" error lists the export values
- form_fields reports a radio group's options and selected value as export
  values
- Test: opt_export_values_select_button_states

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 12:19:36 -04:00
LloydNicholsonandClaude Opus 5.5 5deb42199c Issue #537: pdfcraft-cli --help prints the full usage (#540)
The per-command options lived only in main.rs's doc header, so a packaged
binary had no way to show them. They move into a USAGE constant (one copy,
no drift) that `--help`, `-h`, `help` and `<command> --help` print to stdout,
exiting 0. No command prints the usage to stderr and fails; an unknown
command is named and points to --help.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 12:19:31 -04:00
Hal GoffandClaude Opus 5.5 36af4afb19 Never crash: fall back to OpenGL when wgpu can't start (#461, #435, #392) (#530)
When wgpu can't get a GPU device or surface (old Intel Macs, some Windows
drivers), run_native returned an error and PdfCraft quit before showing a
window. Now, when wgpu fails while starting (eframe::Error::Wgpu, before the
app is created), the error is logged and the app starts again on eframe's
OpenGL (glow) renderer, which the web build already uses.

- PDFCRAFT_RENDERER=gl (opengl, glow) starts on OpenGL; =wgpu never falls
  back; unset or anything else tries wgpu, then OpenGL.
- Window and app set-up move into native_options/app_creator so the second
  run starts exactly like the first; eframe reuses its event loop.
- Page rasters and their tiled backdrop stay within the GPU's
  max_texture_side (OpenGL can report 2048; an oversized upload aborts).


Claude-Session: https://claude.ai/code/session_01VNJqS2dEQawHJn1qTRJiKp

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 12:19:26 -04:00
DimitarandClaude Opus 5.5 bd27555519 M7: Type 3 fallback glyphs get a well-formed d1, so Acrobat draws them (#548)
type3_path wrote `wx 0 0 0 0 1000 1000 d1`: seven operands where d1 takes six
(wx wy llx lly urx ury). Lenient renderers (pdfium, Quartz, MuPDF, poppler) ignore the
extra one, but Acrobat treats the glyph as unusable and draws a bullet for every
character of edited Japanese (or other fallback) text. The box is now the glyph's own
bounds, rounded outward; empty glyphs (spaces) get a zero box.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 12:19:20 -04:00
4e725dcee2 Bookmarks and links go to their destination's position and zoom (#556)
Every destination was reduced to a page index (`dest_page` in the
inspector), so a bookmark written as [p1 /XYZ 0 420 0] went to the top
of page 1 instead of putting y 420 at the top of the window, and /FitH,
/FitV, /FitR and an /XYZ zoom were ignored.

Destinations now keep their view (ISO 32000-2 §12.3.2.2):
- render: `DestView` (Top, Xyz, Fit, FitH, FitV, FitR) parsed from
  explicit arrays, /D dictionaries and named destinations (/Names
  /Dests and /Dests). `OutlineItem` gains `view`; `LinkTarget::Page`
  carries it. Missing, wrong-typed or non-finite operands are null, a
  zoom that isn't positive keeps the zoom, and an unknown type or a
  /FitR without four numbers falls back to the page top.
  `PageInfo::dest_fraction` maps a user-space point to the displayed
  page through the crop box origin, /Rotate and the view rotation,
  clamped to the page.
- ui: `DocView::go_to_dest` applies it for bookmark and link clicks
  (and so for `ui.click` on the control channel): /XYZ puts (left, top)
  at the window's top-left (beside the quick-action gutter) at its
  zoom; /Fit, /FitH and /FitV switch to fit page, width and height with
  top/left at the edge; /FitR zooms so the rectangle fits, centred. The
  /FitB* content box is read as the whole page.

Tests: operand parsing incl. hostile values, a fixture with every form
(named, GoTo, wrong types, huge numbers, no page), point mapping under
crop offsets and rotations, go_to_dest state, and kittest bookmark and
link clicks checking the point lands at the window's top.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 12:19:14 -04:00
19625d40a8 M3: a newly opened document starts at the top, not where the last session scrolled (#554)
Opening a PDF after a restart showed it part-way down, at the page some
other document had been left on in the previous session.

Since #259 (#189) the page view's ScrollArea is salted with the
document's DocId, so each tab keeps its own offset. eframe persists
egui's memory (persist_egui_memory defaults to true) to app.ron every
30 s and on quit, scroll offsets included, and restores it at the next
launch. DocIds start again at 1 every launch, so the first document
opened inherited the offset stored under id 1 last time, the second the
one under id 2, and so on.

A DocView now notes when its page view is first shown, and that frame
sets the scroll offset to zero. Navigation queued before the first frame
still wins: a zoom anchor or `goto` (--page, the PDF's open action,
reopening the last session's files at their pages, #462) is applied on
top. Tabs that were already shown keep their own position, as #189
needs. egui's other persisted memory (panel sizes and the like) is
untouched.

- canvas: `DocView::shown`, reset of the offset on the first frame
- tests/viewer.rs: a_new_document_opens_at_the_top_after_a_relaunch
  saves egui's memory through eframe's own RON storage path after
  scrolling a.pdf to page 4, restores it in a new app, opens c.pdf
  (DocId 1) and checks it starts on page 1 (it opened on page 4 before
  the fix) and then keeps its own position
- parity: the test is evidence on view.document-tabs

Refs #189, #259, #462.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 12:19:10 -04:00
cyfung1031andClaude Opus 5.5 cc17deb634 M3: the watchdog stops the render it gives up on (#522)
When a render runs past `stuck_after`, the watchdog answers with an error,
starts a replacement worker and drops the late result. The abandoned worker
still rendered the page to the end: #409's stop flag belonged to the whole
pool and was only set when the pool was dropped.

Each worker now has its own stop flag. Dropping the pool sets every worker's
flag, as before. The watchdog sets the flag of the worker it gives up on, so
that render stops at its next content operator. Its answer was already thrown
away, so nothing the user sees changes. Live workers are never stopped.

After giving up on 4 heavy renders, the process used 11.2–11.6 s of CPU in
the next 3 s with nothing left to do; now it uses none.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 12:19:04 -04:00
cyfung1031andClaude Opus 5.5 b98505c810 M1: full saves are allocated at their exact size (#523)
`write_full` can't know the output size in advance, so its buffer grows by
doubling and the result kept up to as much again unused: 28 % for a 100 MB
save, 99 % for a 5 MB one. The result is often kept for as long as a document
is open. It becomes the working bytes after edits that need a full rewrite
(repaired files, security changes, redaction) through `write_incremental`, and
it backs Save As ▸ optimized and every new document from Create, Combine,
Extract and Split.

`shrink_to_fit` at the end drops the unused capacity. The bytes are the same.
On macOS the shrink happens in place (2 µs for 100 MB, the buffer doesn't
move), and full-save times are unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 12:18:59 -04:00
Brandon ThomasandClaude Opus 5.5 8ddcf63ff8 Fix #445: prefer the discrete GPU on desktops with a monitor on each GPU (#558)
On Linux, `pick_adapter` preferred the integrated GPU whenever it drove
any display, to keep hybrid laptops (#8) on their low-power GPU. A desktop
with one monitor on the Intel iGPU and one on an NVIDIA card looked the
same, so wgpu picked the iGPU, which accepted the Wayland surface but then
failed to configure it, and egui-wgpu panicked.

`linux_display_gpus` now also records whether a GPU drives a built-in
panel connector (eDP, LVDS or DSI). When several GPUs drive a display,
the one driving the built-in panel wins (laptops, unchanged); with no
built-in panel anywhere, the discrete GPU wins. Tests cover the desktop
topology from the issue, the laptop case, connector classification and
reading the panel flag from a fake /sys/class/drm tree.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 12:06:22 -04:00
Brandon ThomasandClaude Opus 5.5 5ddf33ccf5 Fix #299: typed and drawn signatures stay upright on rotated pages (#562)
On a page with /Rotate 90, 180 or 270, Fill & Sign placed typed and drawn
signatures and initials sideways or upside down: their box and their
strokes/outlines were laid out along user-space axes, which the page turns,
while the click (or fill_sign_add's "at") is a displayed point. Only image
signatures were counter-rotated (#485).

Both now use the same displayed axes as image signatures
(pdfcraft_model::view_matrix_for without its offset): the box's left edge and
vertical centre are at the displayed point, a drawn signature's ink points are
mapped through the displayed right/up vectors, and a typed signature's
normalised outlines are turned back inside a user-space box laid out along the
turned page (the shared signature_image::upright_box, now also used by
SignatureImage::rect). The appearance is plain geometry, so every viewer shows
it upright without relying on a form /Matrix. Unturned pages are unchanged
(the mapping is the identity there).

typed_signature_shape and fill_sign::{signature_at, typed_signature_at} take
the page rotation; fill_sign::place and fill_sign_add pass it.

Tests: a kittest-file test places a drawn, typed signature and typed initials
on /Rotate 0/90/180/270 pages through fill_sign::place, renders them and checks
each displayed ink box is wider than tall, anchored at the point, identical to
the unturned page, and that an asymmetric drawn stroke is not mirrored; an
automation test does the same for fill_sign_add typed signatures and initials,
before and after save/reopen.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:54:07 -04:00
pauliusiv123-rgb 28804d55d7 Issue #430: clear the recent-files list, or remove one file from it (#557) 2026-10-09 11:53:34 -04:00
Brandon ThomasandClaude Opus 5.5 9acb29e418 Fix #527: Shift+click extends the text selection (#560)
A plain click on a page cleared the text selection whatever modifiers were
held, so there was no way to extend a selection with Shift+click as in
Acrobat. Now, when Shift is held and there is a text selection on the
clicked page, the click moves the selection head to the nearest text
position and keeps the anchor. Without Shift, or with no selection on that
page, a click still clears the selection. Selections remain single-page:
cross-page selection (the issue's second request) is not part of this
change.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:48:50 -04:00
Brandon ThomasandClaude Opus 5.5 e5b37b8b9e Fix #454: dependencies keep only line tables in dev builds (#559)
On Windows every integration-test binary links the whole app and gets its
own 500-600 MB PDB, mostly debug info for third-party crates, so one
`cargo test --workspace` needs ~38 GB of target dir.

[profile.dev.package."*"] now sets debug = "line-tables-only". "*"
matches only non-workspace packages (crates.io dependencies and the
vendored crates under vendor/, which are excluded from the workspace), so
our own crates keep full debug info and dependency frames still show file
and line in backtraces.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:48:03 -04:00
Brandon ThomasandClaude Opus 5.5 653f8268c2 Fix #273: ↓ / ↑ turn pages in single-page view (#550)
In single-page view ↓ / ↑ only added to the pending keyboard scroll, so on a
page that fits the window (or once scrolled to the page's end) they did
nothing, while the mouse wheel turned the page there.

The page view now records, after each frame in single-page view, which page
it drew and whether it was at the top and at the bottom of it. ↓ at the
bottom (always, on a page that fits) steps to the next page; ↑ at the top
steps to the previous page and lands on its bottom, so holding ↑ keeps
scrolling back through the document. The edges only count when they belong
to the current page with no jump pending, so a stale frame can't turn two
pages for one press.

Tested by a kittest: Fit page in single-page view, ↓ / ↑ turn one page per
press; Fit width, ↓ scrolls through page 1 before turning, and ↑ from the
top of page 2 returns to the bottom of page 1.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 10:50:59 -04:00
Brandon ThomasandClaude Opus 5.5 6fce73f902 Fix #429: pen strokes stay unselected and show no author popup (#549)
Cause: apply_edit selected every new annotation after Edit::AddAnnotation,
so each stroke drawn with the Draw (ink) tool became selected and showed
its selection box over the page. With the pointer still resting on the
fresh stroke, the canvas hover popup also showed the comment's author,
covering where the next stroke goes.

Fix: a new Ink annotation made while the ink tool is active is left
unselected (other annotation types, and ink added any other way, are
still selected). The annotation hover popup is suppressed while the ink
or eraser tool is active, as it already was mid-gesture.

Test: kittest draws two pen strokes in a row and checks both exist,
nothing is selected, the pen stays on and no author popup appears; a
square drawn afterwards is still selected.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 10:50:17 -04:00
Brandon ThomasandClaude Opus 5.5 2aca0692fc Fix #526: keep text colour in Word, HTML and RTF export (#547)
The export model (`pdfcraft_export::Block`/`Cell`) had no colour, so
`export_pages` dropped the fill colour that `edit::TextBlock` already
carries and every run came out black.

Block and Cell now carry an sRGB colour, mapped from TextBlock.color.
Word runs get `<w:color w:val="RRGGBB"/>`, HTML paragraphs, headings and
cells a `style="color:#RRGGBB"`, and RTF a colour table with `\cfN`.
Black text keeps each format's default; NaN and out-of-range components
clamp.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 10:49:21 -04:00
Brandon ThomasandClaude Opus 5.5 a593512700 Refs #347: encode the full WinAnsi 0x80-0x9F range (Š š Ž ž Œ œ Ÿ ...) (#546)
pdfcraft_fonts::win_ansi, edit's is_win_ansi_char and the xtask demo
encoder each carried a hand-written subset of WinAnsiEncoding's 0x80-0x9F
row (quotes, dashes, euro, trademark) and left out Š š Ž ž Œ œ Ÿ ƒ † ‡ ˆ
˜ ‰ ‹ ›. Text editing therefore treated š/ž as non-WinAnsi and sent them
to the Type3 fallback font (or refused them without craft-fonts), and
every other generator (forms, annotations, OCR layer, redaction overlay,
signatures, create) wrote them as '?'.

win_ansi now goes through a new win_ansi_byte, which reads the 0x80-0x9F
codes from the same ISO 32000-2 Annex D table the text extractor decodes
with, and is_win_ansi_char reuses it. xtask doesn't depend on
pdfcraft-fonts, so its demo encoder gets the full row with a pointer to
the shared table. č/ć are not in WinAnsi and still use the fallback.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 10:45:28 -04:00
Brandon ThomasandClaude Opus 5.5 bd57377435 Fix #525 (item 4): phone pattern redacts international numbers whole (#543)
The Phone Numbers redaction pattern accepted only 10 digits (11 with a
country code), the North American form. For "+49 2151 123456" (12 digits)
the `+` candidate was rejected and the scan then matched "2151 123456",
leaving "+49" visible: a partial redaction in a document the user thinks
is clean.

The pattern now accepts, still grouped by at least one separator:
- E.164 numbers with a leading `+`: 8-15 digits in all;
- national numbers with a trunk 0 followed by 6-14 digits, whose first
  group has at least two digits (so decimals don't match) and which are
  not a day-first date (left to the date pattern);
- North American numbers as before.
A separator may now be followed by `(`, so "+49 (0) 2151 123456" is one
match instead of a partial "(0) 2151 123456".

Refs #525. The other items (page-number and date tokens, stamps) are not
addressed here.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 10:43:11 -04:00
7c358099ff M14: German interface catalog (de) (#471)
* M14: German interface catalog (de)

Deutsch (`de`) joins the interface languages as a complete catalog of
2,035 entries: every command, All tools entry, dialog, panel and notice.
It is selected from Preferences, and Auto picks it for `de`, `de_DE`,
`de_AT`, `de_CH` and other German locales. Wording uses the informal
"du" and the infinitive for commands; translations are clean-room, written
from the meaning of the English labels.

- de.tsv registered in LANGUAGES (plural rule one|other)
- tests: registration and locale mapping, plurals, coverage of every
  command, All tools label and tl! literal, history labels keeping user
  values; the Latin UI faces cover umlauts, ß and German quotes; the
  control-channel language switch includes `de`
- ATTRIBUTION.toml/.md entry (kind = "translation"), docs/localization.md,
  ROADMAP and parity notes

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* M14: register German as the 12th language; translate the reopen-session preference (de, bg)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 09:26:15 -04:00
21a9624585 M7: Arabic in added text (shaping, bidi order, searchable Type 3 glyphs) (#403)
Add text wrote Arabic as "?" (standard-14 fonts, WinAnsi). Items with
Arabic are now shaped with the craft-fonts Arab face through harfrust
(already in the tree via egui) and laid out line by line in display
order with unicode-bidi (UAX #9), so Arabic mixed with Latin, digits and
brackets reads correctly. Each cluster (a letter with its dots or marks)
is one glyph of a Type 3 font drawn from the face's outlines, with a
ToUnicode entry, so text extraction, copy and search get every letter
once and no font file is embedded.

- fonts: shape_arabic / arabic_glyph / arabic_has / document_arabic_font;
  japanese_glyph's bounded outline code is shared as bounded_outline.
- edit: draw_arabic and type3_arabic; wrapping measures shaped words.
  Fonts get names free on the page (a full save renumbers objects, not
  resource names) and are removed when the item is updated or deleted.
  Outlines are read before anything is written.
- Without the face, adding Arabic is a clear error that changes nothing;
  an existing item holding Arabic still moves, drawn as "?" as before.
- Non-Arabic items are drawn exactly as before.

Refs #250, #280, #369.

Co-authored-by: fares <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 09:22:50 -04:00
ee06583523 Never crash: JBIG2 symbol dictionaries can't loop without end (#408)
`cargo xtask fuzz` found a 1.1 KB page whose JBIG2 image never finished
decoding (over two minutes, flat memory). It differs from a valid file by
one byte inside a symbol dictionary's arithmetic-coded data. Past the end
of its data the arithmetic decoder keeps returning values, and two loops
in symbol.rs only move on when those values make progress:

- Export runs (6.5.10): a run of length 0 doesn't advance, and the
  decoder returned 0 for ever. This was the fuzz finding.
- Height classes (6.5.5): a class whose first width is already the
  end-of-class marker decodes no symbol. A read-only sweep of the crate
  found this one. In a synthetic dictionary every class after the first
  symbol adds 13 to the height and decodes nothing, so it only fails
  when the height overflows u32, after 330 million classes (about five
  seconds per image, logged); a delta of 0 would never end.

The sweep found every other loop in hayro-jbig2 bounded by a symbol,
instance or pixel cap or by the end of the data. Bounded is not cheap
everywhere: refinement bitmaps in text regions and aggregate symbols
(up to 10,000 instances of up to 2^28 pixels each) are not changed
here.

Vendored hayro-jbig2 patch (3): a valid dictionary needs at most one
height class per new symbol and `2 x symbols + 1` export runs (they
alternate, so at most one zero-length run before each symbol's). Both
loops get that budget plus `EXTRA_EMPTY_STEPS` (1024), then fail the
dictionary as invalid. Encoders don't write empty steps back to back,
so the slack only tolerates oddities.

The fuzz finding now renders in 0.2 s. Synthetic regression test in
render: two dictionaries of a few arbitrary bytes each, found by
searching for streams that decode the same way (no corpus bytes). The
height-class one is drawn as twenty images so that it stalls well past
the 20 s timeout without the patch on any machine. Each case is red
with its own budget removed and green with it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 09:22:21 -04:00
Brandon ThomasandClaude Opus 5.5 49fb1b1b35 Tests: one wgpu device compiles shaders at a time in fill_sign.rs (WARP ARM64 crash) (#395)
The image-signature tests (#335, #355) render pixels in parallel; on the
Windows 11 ARM64 runner WARP's pixel-shader JIT then crashes the test process
with STATUS_ACCESS_VIOLATION (3 of the last 4 main runs). Take the same GPU
lock tests/view.rs uses (d341651) in every fill_sign test that renders.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 09:21:57 -04:00
Brandon Thomas 2f98beba54 Merge pull request #272 from XusBadia/dmg-background
macOS DMG: branded window background and icon layout
2026-10-09 09:21:44 -04:00
Brandon Thomas 37542ad9db Merge remote-tracking branch 'origin/main' into dmg-background
# Conflicts:
#	ATTRIBUTION.md
2026-10-09 09:21:35 -04:00
midasdfandBrandon Thomas 32fbef684a M5.7: keep natural image stamps upright on rotated pages (#473)
Refs #434 (partial). Preserve displayed bounds and standard appearance orientation through stamp edits; image signatures and explicit rectangles remain outside this fix.

Assisted-by: OpenAI GPT-6.1 (gpt-6.1-sol)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 09:21:31 -04:00
8c90a8d1fd M2: bound lopdf predictor scratch to available samples (#470)
* M2: bound lopdf predictor scratch to available samples

Return empty PNG predictor frames before allocating row buffers. Reject
overflowing packed TIFF row dimensions and bound component scratch to the
samples present in the input, aligning with the existing lopdf#589 fix.

Cover hostile dimensions and valid packed rows with synthetic regressions
and record the vendored patch and its upstream proposal.

Co-Authored-By: OpenAI Codex <[email protected]>

* Keep main's M1 roadmap row after merging main

---------

Co-authored-by: OpenAI Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 09:21:21 -04:00
DimitarandClaude Opus 5.5 79eadc2aa8 M14: Bulgarian (Български) interface language (#466)
A complete clean-room catalog (2,035 entries: every command, All tools
label and tl! literal), registered as `bg` with one/other plurals so Auto
picks it on Bulgarian systems. Tests mirror the Russian ones (registration,
plurals, persistence, command and literal coverage), and Bulgarian joins
the Latin/Cyrillic font-coverage test.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 09:16:16 -04:00
pauliusiv123-rgb 3de2be8a68 Issue #442: reopen the files that were open when PdfCraft last closed (#462) 2026-10-09 09:16:10 -04:00
cyfung1031andClaude Opus 5.5 3a35a7d6ac M1: incremental saves are allocated once at their exact size (#402)
`write_incremental` copied the original into a buffer with 4 KB to spare,
then appended the revision. A revision above 4 KB (rotating a few dozen pages,
an image, a long comment thread) outgrew it. The buffer was then reallocated
at twice the size: the whole original was copied a second time, and the
result kept up to the original's size again unused. The engine keeps that
result as the document's working bytes, shared with the renderer and handed
to Save, so the unused half stayed alive. A 100 MB document held a 200 MB
allocation after one such edit, and still did after saving.

The revision is now written on its own, with offsets counted from where it
will start. The result is allocated once at exactly the original's size plus
the revision's, and filled in one pass. The output is byte for byte what it
was before.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 09:16:01 -04:00
cyfung1031andClaude Opus 5.5 d6c2a694ec M3: rendered pixels are moved out of the pixmap, not copied (#404)
`render_page` copied every raster out of vello_cpu's pixmap with
`data_as_u8_slice().to_vec()`. That is a second buffer the size of the raster
(up to 256 MB for a 64 MP page), alive alongside the first for as long as the
copy runs. `Pixmap::take` hands over the pixel vector, and `bytemuck`
reinterprets its four one-byte channels as bytes in place: the same bytes, in
the same buffer.

On a 6182×8000 px page, peak RSS goes from about 425 MB to 246 MB and render
time from 102 ms to 81 ms. Pixels are identical: every page of #260's test
file, at two scales and as a tile, hashes the same before and after.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 09:15:55 -04:00
cyfung1031andClaude Opus 5.5 e130af2ef4 M3: renders of a dropped render pool stop at the next operator (#409)
The engine replaces a document's render pool after every edit, undo, redo,
layer toggle and comment-visibility change, and drops it on close. Workers in
the middle of a page kept rendering it to the end before finding that nobody
could receive the answer. On heavy pages that is seconds of CPU per worker,
competing with the new pool re-rendering the same pages.

hayro-interpret, vendored patch 17: `InterpreterSettings::cancelled`, checked
before each content operator. The pool sets it only in `Drop`, when its result
channel is gone, so a partly drawn page can never be delivered. Live pools,
`PageRenderer` and inline pools never set it.

7 heavy pages on 7 workers re-rendered after replacing a busy pool: 2.4–2.5 s
before, 1.1 s after (1.0–1.3 s without a replacement, unchanged).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 09:15:47 -04:00
jcl-bitandClaude Opus 5.5 8ce0a905bf Never crash: dash patterns can't exhaust memory or hang the renderer (#405)
`cargo xtask fuzz` found a 392-byte page whose ExtGState sets
`/D [[11] 0]` on a line that starts at x = 92234775807. Stroke expansion
keeps every dash, so it cut the line into billions of them and allocated
more than 5 GB before the run was stopped. Triage found the same class
in ordinary-looking geometry, and a second bug next to it:

- A tiny dash on an ordinary line (`[0.000001] 0 d` on 20 pt) passed
  10 GB in under two seconds; so did `[1] 0 d` under a shrinking matrix,
  and a long pattern on many short subpaths, because kurbo restarts the
  pattern at every subpath. The render watchdog can skip a stuck page but
  can't stop it allocating, so on a desktop this ends in an abort that
  takes every open document with it.
- A dash array whose entries sum to less than zero (`[-1 -1] 0 d`) made
  kurbo's search for the starting dash loop forever. ISO 32000-2
  §8.4.3.6 requires nonnegative entries.

Vendored hayro patch (5, 6): `usable_dash_pattern` bounds the pieces a
pattern can cut the path into, in user space where kurbo dashes:
(control-polygon length / period + restarts) x entries per period, where
restarts are kurbo's: every MoveTo and ClosePath. Over 250,000 the stroke
is drawn solid, as it is for a pattern with a negative or non-finite
entry or a period that isn't positive. Measured in release builds: a
250,000-piece stroke takes about 16 ms and 75 MB at its peak (a million
took 80 ms and 290 MB). A non-finite phase becomes 0.

Every case now renders in about 0.2 s and a few MB. Synthetic regression
tests in render. Both were red before the fix (the first killed at
2.7 GB, the second stalled past 20 s), and four mutations each turn them
red: the first estimate (one piece per subpath) with a 100k cap runs out
of memory, a 100k cap draws a fine legitimate dash solid, dropping the
negative-entry check draws `[-1 3]` dashed, and dropping it together
with the period check hangs. (The period check alone is defence in
depth: hayro-interpret turns 0 entries into 0.01, so a pattern without
negative entries always has a positive period.)

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 09:15:38 -04:00
fa52469dbe sign: read the .p12 files Windows writes (BER, not DER) (#453)
Importing a digital ID exported by Windows failed with "PKCS #12: not a
PKCS #12 file" — at the second byte of the file, before the password was
looked at. Windows writes a .p12 in BER: the PFX, its ContentInfo and
the [0] around the AuthenticatedSafe all use the indefinite length form
(0x80 … 00 00), and the AuthenticatedSafe itself is an OCTET STRING
split into segments. der.rs took definite lengths only, so every one of
those files was turned away. OpenSSL writes definite DER, which is why
the fixtures never showed it.

A Tlv now carries the rules it was read with, and its children are read
the same way: Tlv::parse is the strict DER reader it always was,
Tlv::parse_ber also takes the indefinite form, and Tlv::octets joins the
segments of a constructed OCTET STRING, borrowing when there is nothing
to join. Both are depth-bounded. pkcs12 reads the envelope with the BER
reader; x509 and cms keep the strict one, so nothing that is verified
over its own bytes changes.

The MAC decides what is allowed here: it covers the contents of the
authSafe OCTET STRING, the segments joined, which is what the writer
MACed. So the file is never normalised to DER — that would change the
octets the MAC was computed over and turn a right password into a wrong
one.

The test builds the Windows shape out of the OpenSSL fixtures, twice:
the outer wrappers in BER with the MacData intact, which is what proves
the MAC is taken over the right octets, and then the AuthenticatedSafe
re-encoded as well, with the MacData dropped (RFC 7292 §4 makes it
optional), for a file that is BER the whole way down.


Claude-Session: https://claude.ai/code/session_013Q5dhMC58jk1ePNW37xe1b

Co-authored-by: Fernando Areán <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
2026-10-09 09:15:32 -04:00
midasdf 57c281ca0a M3: keep overflowed document tabs reachable (#416)
Refs #397. Scroll the tab strip, reveal the active tab and preserve the existing close-confirmation path.

Assisted-by: OpenAI GPT-6.1 (gpt-6.1-sol)
2026-10-09 09:15:24 -04:00
midasdf c8c7959c0f M4.6: search bookmark titles without changing the outline (#424)
Refs #257. Show matching entries and their ancestors, keep original edit paths and preserve per-document view state.

Assisted-by: OpenAI GPT-6.1 (gpt-6.1-sol)
2026-10-09 09:15:18 -04:00
Wesley HainesandClaude Opus 5.5 3543ca8296 Edit text: fast drags on a paragraph's edge no longer get dropped (#436)
The rewrap handle took the drag target from the pointer's current position.
egui only reports drag_started() once the pointer has moved 6 points (or the
button was held 0.8 s), so a quick outward flick had already left the 7x14 px
handle and the drag was ignored. Resolve the target from press_origin(), as
prepare.rs does, before the hover early-return.

Also: the left edge rewraps too (width plus an x offset), and edge grips span
the box's full height, 6 px either side of the edge.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 09:15:13 -04:00