Commit Graph
196 Commits
Author SHA1 Message Date
892f0b78f0 M14: give Windows PDF files a dedicated document icon (#890)
* M14: give Windows PDF files a dedicated document icon

Embed the document icon as resource 2 through the existing Windows resource
build and select it in both PDF association registrations. Keep the asset
reproducible and carry its required licence notices in Windows packages.

Formatting, strict Clippy, 1,842 existing workspace tests, all 30 WASM crate
checks, assets, layering and parity pass. The Windows x64 release package
checks pass, and the contributor confirmed the icon in Explorer.

Co-authored-by: Codex <noreply@openai.com>

* Refresh catalog hashes after merging main

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 17:21:30 -04:00
9ccf77bdf6 M4: traverse large bookmark trees lazily without dropping later items (#876)
* M4: traverse large bookmark trees lazily without dropping later items

The outline walk stopped silently at 100,000 bookmarks, and opening a
document built the whole nested outline with the same cap. Both walks also
recursed once per level and overflowed the stack on a deep chain.

organize walks the outline lazily and iteratively with no item cap, and
lists it a page at a time (bookmark_page). render keeps a bounded window of
the first 10,000 bookmarks, nested at most 33 levels deep, and reports when
more exist. automation's bookmark_list pages with offset and limit, and
doc_info reports outline_more. The bookmarks panel says when bookmarks are
left out.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* M4: keep the bookmarks panel note in English

The note was a tl! label, and the coverage tests require every tl! label to
exist in each complete catalogue. This PR adds no translations, so the note
is now a plain English label in every locale. The i18n catalogues are
restored to origin/main.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* Bookmarks: translate the panel's limit notice; a numeric destination names a page in the document

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 17:17:13 -04:00
mitul72andBrandon Thomas 8196d6f45a M14: interface size preference; Auto follows GNOME text scaling (#902)
* M14: interface size follows the system text scale

Preferences ▸ Interface size scales the whole interface (75–250 %, setting
`ui_scale`, control option `ui-scale`). Auto, the default, follows the
desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's
`text-scaling-factor` once at launch (through `gsettings`, with a 500 ms
limit; anything unusable counts as 100 %). With Large Text (1.25) on a 1×
display, the interface no longer draws 13 px labels that show their pixels
next to the system's text.

The size is egui's zoom factor, so `pixels_per_point` grows with it and page
rasters stay one texel per screen pixel (`canvas::render_scale`). It is
applied only when it changes, so a zoom set elsewhere (the test harness's
`pixels_per_point`) isn't undone every frame. egui's own ⌘+/⌘−/⌘0 interface
zoom is turned off so those keys stay page zoom.

Tests and headless screenshots keep 100 %: only the desktop app sets
`system_text_scale`. New labels are translated in every catalog, and the
catalogs' hashes in ATTRIBUTION.toml are updated.

* M14: offer a 50 % interface size

On a display the system reports at 200 %, the smallest size the menu offered
gave a 150 % interface (#456). 50 % brings it back to 100 %; settings and the
control channel already accepted it.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 17:13:52 -04:00
80aa08andBrandon Thomas 603cefe213 M14: Add Polish interface language (#782)
* M14: Add Polish interface language

* Polish catalog: attribution entry; format the Polish date names

* Polish catalog: keyboard key names

* Refresh catalog hashes after merging main

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 17:03:47 -04:00
+21 f74c19de87 Combine files: move and remove single pages before combining (3 of 3, closes #611) (#790)
* Combine files: a thumbnail grid, card sizes and card actions (refs #611)

Combine files shows each file as a card (the default; Grid/List kept in
the settings, --combine-view): the first page it adds, stacked when it
adds more, its name shortened in the middle. Cards select (click, Ctrl,
Shift, arrows by card and by row), drag to reorder (one undo step; the
grid scrolls at its edges) and share the list's toolbar, selection and
history. Locked, unreadable, bad-range and unmeasurable files say why.

Thumbnails render off the UI thread, only for cards in view: at most
three render threads at once, counted until they exit (no watchdog
replacements), a 32 MB texture budget, keys that never show another
page, password or file, and one heavy step per frame on the web.

Cards are sized from 60% to 200% (toolbar slider and buttons, Ctrl
+/-/0, Ctrl+wheel or pinch over the grid, kept in the settings,
--combine-zoom), keeping the card under the pointer in place. Hovering
a card shows its actions over the top of its page: remove that file,
and a magnifier that shows it large, flipping through the pages it
adds; the same on a right click, and Space by keyboard. The toolbar
wraps between groups at narrow widths.

RenderPool gains retire(), without_replacements() and threads() so a
caller can bound the render threads that exist at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Combine files: expand a file into its pages (refs #611)

A file that adds more than one page (up to 2000) expands into a card
per page it adds, in its range's order, each labelled with its page and
the file's name, on a shared tinted band: from its card's hover bar, a
right click, or Expand all / Collapse all in the toolbar. A page card's
magnifier opens the preview on that page; Up and Down step rows of
cards. Expanding is a view: not an undo step, kept across views and
zoom, and the combined file is the same either way.

Thumbnails are now kept per file and page, looked up on demand (the
cost follows the files, not the pages of an expanded file), and a
render's document stays open for two seconds so the next page of the
same file isn't read again.

Adds the Lucide minimize-2 icon (ISC, recorded in ATTRIBUTION).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Combine files: move and remove single pages before combining (closes #611)

With a file expanded, single pages are selected (click, Ctrl, Shift),
dragged anywhere and removed (a page card's trash, the toolbar, Delete),
each one undo step; the files themselves are never changed. A page moved
between another file's pages splits its file into parts that share its
bytes: the list shows each part as a row with its own page range, and
parts that end up side by side join again. A split file still counts,
unlocks and combines as one file.

organize gains combine_grouped (and Session::combine_grouped): each file
is copied once and its pages put in the order shown, so it keeps one
bookmark at its first page, its nested bookmarks, links between its own
pages, its fields and one copy of its attachments. A page listed twice
now gets its own copies of its annotations, and a form field's widget on
it joins the same field, so the field keeps one value. The existing
combine is unchanged; doc_combine gains an optional groups
array (entries with the same number are one file split into parts), so
the same combine is reachable headless.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#873)

Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, PDF-spec, hardware, UI and localization parity docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Measure: snap to the implicit closing edge of filled paths (#803) (#862)

* fix(organize): reject rotation angles that are not multiples of 90 (#801) (#833)

Page rotation only turns in quarter steps, but the degree value was read with an integer division by 90 that threw the remainder away. Asking for 45 degrees saved an unrotated page, 91 and 135 saved a 90 degree turn, and the command still reported success.

Reject the value before touching the document instead, so every caller gets an actionable error and the pages are left alone. The automation tool already rejected it; the single edit command did not.

Fixes #801.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* Autosave: snapshot a new unsaved document before its first edit (#815) (#850)

* OCR: Recognize text in multiple files refuses paths outside the root (#795)

ocr_recognize_files turned a path outside the automation root into a per-file
error and returned success, so no_tool_escapes_the_root reported 9 leaks once
the OCR models were installed. Without them, loading the models failed first
and hid it, and CI never installs them. Every path is now checked before any
work starts, and one outside the root refuses the whole call, like the other
tools. A missing file inside the root stays a per-file error. Nothing was ever
read from outside the root.

ocr_recognize_files_refuses_paths_outside_the_root checks this without the
models.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(text): match phrases across repeated whitespace (#814) (#831)

The phrase search collapsed runs of whitespace in the query but not in the page text it was matching against, so a document drawn with two space glyphs in a row like the issue example never matched the phrase. Single words still matched, which is why it looked like only some files were affected.

Collapse whitespace while the text is flattened, the same way the query is normalised. The regression test fails on the old code because the phrase find returns an empty range, and it also covers tabs and single word queries.

Fixes #814.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* fix(edit): keep a comment whose appearance is missing when flattening (#805) (#838)

Viewers draw notes, FreeText, ink and stamps from their dictionary when the file stores no /AP /N. flatten only drew annotations that already had an appearance and then removed the rest, so a supported note disappeared and the saved page came back blank.

Build the appearance a viewer would show for every selected, visible, non-widget annotation that has none, then draw it as usual. A subtype this build cannot render still fails to build and is handled exactly as before.

The new synthetic test draws the note icon into the page and asserts it is baked in; without the fix flatten reports 0 drawn and the icon is lost.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* fix(comments): keep a reply to a reply in the comment listing (#806) (#839)

comment_list collected only the replies whose /IRT pointed at the root comment, so a reply to a reply was listed nowhere at all. Roots and the root count were already right; the nested reply was simply dropped from its thread.

Follow the /IRT chain to any depth, with a bound, so every reply under a comment is listed in document order. A reply to a reply now stays discoverable through the tool that promises every comment and its replies.

The new test builds a root note, a direct reply and a reply to that reply, then checks both texts are listed and the two notes stay roots. It listed only the direct reply before the fix.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* Issue #774: keep the catalog out of object streams in encrypted files (Acrobat) (#836)

Acrobat reports an encrypted PDF as "damaged and could not be repaired", before
asking for the password, when the document catalog sits in an object stream.
Every other reader tried (qpdf, pypdf, poppler, PDFKit, PdfCraft) accepts such
files, and qpdf keeps the catalog standalone too. Full saves of encrypted
documents now write the catalog as its own object; unencrypted output is
unchanged. Verified with Acrobat DC on macOS: the same file fails with the
catalog packed and opens with it standalone, in AES-256 and RC4-128.


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

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(edit): refuse a header start number that would wrap the next page (#804) (#834)

Page numbering adds the start number to the position of the page in the run. At the top of the u32 range that addition wrapped, so a release build saved zero for the second page and still reported success, and a debug build hit a caught overflow error instead.

Check the whole run before drawing anything. When the last page number would pass the u32 limit the operation returns an actionable error and the document keeps no header, rather than a half applied run.

Fixes #804.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* OCR: a scanned euro sign reads as €, not E (#796)

The ocrs crate's default alphabet has an E where its recognition model was
trained with € (ocrs-models DEFAULT_ALPHABET), so a scanned "€250" was
recognised as "E250". pdfcraft-ocr now gives ocrs the alphabet the model was
trained with.

the_alphabet_is_the_trained_one checks the alphabet without the models;
recognize_text_reads_the_euro_sign reads a scan end to end when they are
installed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(comments): include replies to replies in the comment summary (#820) (#840)

The comment summary gathered a comment's direct replies only, matching /IRT against the comment itself. A reply to a reply points at the direct reply, so its text never reached the generated summary even though the stored discussion kept it.

Replies are now walked to any depth, with a bound, and indented one level per reply. The existing output for direct replies is unchanged.

The new test builds a rectangle, a reply and a reply to that reply, then checks the summary carries both texts with the nested one indented under its parent. It omitted the nested reply before the fix.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* fix(xfdf): drop the replaced note's popup when re-importing a comment (#807) (#860)

* fix(view): check Shift-Command-G before Command-G for find navigation (#810) (#835)

* view: check Shift-Command-G before Command-G for find navigation (#810)

* Tests: the find shortcut test waits for find by a deadline

(cherry picked from commit eaf3180bd8ebb353b3d8c5ca7b9ef7548ed55c37)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* fix(editing): recognize paragraph drafts as unsaved work (#811) (#843)

* view: check Shift-Command-G before Command-G for find navigation (#810)

* fix: recognize paragraph drafts as unsaved work (Issue #811)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* fix(automation): retain batch results with identical filenames (#877)

* fix(automation): retain batch results with identical filenames

* Format

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M13: preserve desktop Action Wizard batch results (#886)

* M13: preserve desktop Action Wizard batch results

Co-Authored-By: Codex <noreply@openai.com>

* ROADMAP: date the desktop action outputs entry today

---------

Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Fit to width fits the shown page in single-page view (#808) (#859)

* fix(links): find URLs when lowercasing changes the text length (#816) (#830)

find_urls case-folded a copy of the page text and then sliced the original
characters with offsets taken from that copy. Case folding can change a
string's length — U+0130 (İ) lowercases to "i" plus a combining dot — so the
offsets drifted past the end of the source and `chars[i..j]` panicked. The CLI
exited 101 before it could create the link; MCP reported an internal tool error
but kept serving.

Match the ASCII prefixes case-insensitively against the original characters
instead, so every offset stays in the source's own index space, and use get()
so no lookup can index past the end.

A new test reproduces the reported panic on the old code (range start index 41
out of range for slice of length 39) and passes with the fix. pdfcraft-annot's
tests, clippy and rustfmt are green.

Fixes #816.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* organize: deleting the opening page drops the catalog OpenAction (#821) (#855)

* fix(text): Turkish dotted and dotless i match in case-insensitive search (#892)

Case-insensitive search folded the query and the page with
str::to_lowercase, which follows no locale: U+0130 (İ) becomes "i" plus a
combining dot above (two characters) and U+0131 (ı) has no case pair. So
"istanbul" never found "İSTANBUL", and "ırmak"/"kış" never found
"IRMAK"/"KIŞ" (or the reverse). The same PageText::find serves Find,
Search & Redact (phrases and word lists) and the automation text search,
so Turkish words could also be left unredacted.

Fold I, i, İ and ı to "i" and every other character with its own
lowercase, one character for one character. Case-sensitive search is
unchanged.

* XFDF keeps polygon and polyline vertices (#809) (#848)

Export wrote polygon and polyline comments without their points and import
had nowhere to read them from, so an XFDF round trip left the comments with
no /Vertices and no appearance. Export now writes the points as XFDF
<vertices>x,y;x,y;…</vertices>, and import reads them back (whole x,y pairs
only) before the appearance is drawn.

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* XFDF keeps a callout's leader, text-box inset, intent and arrowhead (#819) (#854)

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Web: warn before reloading a document with unsaved changes (#812) (#856)

* Issue #813: a cancelled recovery password no longer attaches to the next unlocked document (#858)

Recovering an encrypted snapshot parked its metadata in `pending_recovered`
until the password prompt was answered. Cancel left it there, and the next
encrypted document unlocked in the foreground took the snapshot's original
path and recovery key: Save would overwrite the other file and delete its
snapshot.

The association now travels with the prompt (`PasswordPrompt::recovered`).
Cancel puts the entry back on offer and leaves the store alone; a wrong
password keeps it on the re-raised prompt; the correct one finishes the
recovery of the tab that was just opened. `pending_recovered` is gone.

Test: crash with an encrypted snapshot of A, Recover, Cancel, open encrypted
B with its password. B keeps its own path and is clean, A's snapshot stays
in the store, also after closing B. Fails on main at "B keeps its own path".

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* Viewer: touchpad scrolling tracks the finger 1:1 (#759) (#868)

A precision touchpad sends pixel-precise `MouseWheel` events. On Windows
they arrive with no `TouchPhase::Start`, so egui never takes its
no-smoothing "in touch" path and eases any delta of 8 px or more over
several frames, leaving the page a frame behind the finger.

The page view now applies precise (Point-unit) wheel input itself, 1:1,
through the same un-eased scroll path as keyboard and middle-button
auto-scroll, and suppresses egui's eased copy for a short window so its
internal easing tail neither doubles nor trails the motion. A coarse
mouse wheel (Line/Page units) keeps egui's smoothing, which is pleasant
for chunky notches, and single-page paging still owns the vertical axis.

Regression test: one 200 px precise swipe scrolls the full 200 px at
once (an eased path would ramp there over ~0.1 s) and leaves no drift.

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Keep every part when bookmark titles repeat in doc_split (#800) (#863)

* Windows: add reproducible security diagnostics for storytold/pdfcraft#747 (#769)

Add packaging/windows/security-audit.ps1, a read-only maintainer helper that records a
release file's SHA-256, Authenticode status and, with an external YARA binary and rule,
every matched string identifier and offset. Document the procedure in docs/releasing.md
("Scanner detections").

No application, dependency, installer or network behaviour changes: the diff touches only
the helper and the release documentation.

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* Notice bar: the message wraps beside its buttons and lines up with them (#785) (#832)

The notice bar above the pages (security, forms, XFA, repaired file,
warnings, signatures) laid its message out before its buttons. In a
narrow document area (a side panel open, a longer translation) the
message ran under "Security settings" and on into the side panel, and it
wasn't centred on the buttons.

A shared notice_bar helper now lays the buttons out first, right to
left, centred on a 28 px row, and gives the message the width that's
left: it wraps there (cut short with the full text on hover when less
than 120 px is left) and its first line is centred on the buttons' row.

Regression test: editing::the_security_notice_wraps_beside_its_buttons_and_lines_up_with_them
(one line, wrapped and cut-short widths; fails on main).

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* M10.2: scan JPEG metadata beyond frame headers (#764)

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Issue #878: Combine stores an image once when its colour space is an indirect name (#883)

ImageMagick writes an image's colour space as its own object (`/ColorSpace 10 0 R`,
`10 0 obj /DeviceRGB`). Each source's copy of that object was never merged, since it
isn't a resource, so identical images compared unequal and every copy was kept:
8 copies of an 80 MB ImageMagick PDF combined into 648 MB instead of 81 MB.

Dedupe now compares a reference to a plain value (name, number, boolean or string) as
that value. The value objects themselves are still never merged. Combine, Insert Pages
and Reduce File Size share this code.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* organize: drop named destinations and their links when a page is deleted (#817) (#851)

* JavaScript: limit util.printf widths and output, and array arguments (#770)

util.printf accumulated a conversion's width and precision with unchecked
arithmetic and no maximum, and its output had no bound; the array
arguments of resetForm and the field value/colour setters were read for
as many items as the array's length says, which a sparse array makes
free. Saturate the numbers and refuse widths and precisions above 4096,
output above 1 MiB and arrays longer than 2^20 with a script error,
before anything is allocated from them.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Linux: "Use system setting" asks the desktop, not winit (#881)

winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is
`None` there on every desktop — so `ThemePreference::System` never saw the
desktop's choice and silently fell back to whatever was on screen: PdfCraft
came up light on a dark desktop, and picking "Use system setting" after Dark
kept it dark. The default setting did not follow the system.

`pdfcraft-platform` now reads the XDG Desktop Portal's Settings interface off
the UI thread and the app follows the answer, including a change made while it
runs. The colour scheme is asked for under its GNOME schema name first, which
GNOME's and XFCE's portals both answer, then under the older freedesktop one,
which KDE answers. A desktop that answers neither leaves the user's own choice
alone, exactly as before.

Co-authored-by: GyuriX <gyurix@users.noreply.github.com>

* Extract/Combine: a page repeated in one selection gets its own annotations (#818) (#852)

* M5.7: Fill & Sign tools pick up placed signatures to move and resize them (#822)

A signature placed with Fill & Sign could only be moved with the Select
tool. While a Fill & Sign tool was active (Sign, Initials, Add text,
Check, Date...), hovering the signature showed the placement crosshair,
a drag did nothing and a click stamped another mark on top of it.

Now, as in Acrobat, a Fill & Sign tool picks up the Fill & Sign marks
already on the page: hovering one shows the move cursor, a click selects
it, a drag moves it and the selected mark's handles resize it. Away from
them the tool places as before, including on other comments. Image
signatures keep their live move/resize preview.

Comments now carry a fill_sign flag (pdfcraft_annot::is_fill_sign for
the engine's list, the same rules in the viewer's inspector), so this
also works on reopened files.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Web: opt-in embedding bridge for host pages (?host=parent) (#842)

A page that frames PdfCraft on the same origin can now pass it PDFs and
receive saves through postMessage, instead of Save becoming a browser
download. Built for a Nextcloud integration, but nothing in it is
host-specific.

- ui-egui: HostSaveFn/HostDirtyFn hooks (`host_save`, `host_dirty`),
  None by default. On wasm, Save hands the bytes to `host_save` when
  set; `host_dirty` hears each frame whether any tab has unsaved work.
- pdfcraft-web: `host::Bridge`, enabled only by `?host=parent` inside a
  frame. Messages: pdfcraft:ready / open / save / dirty / failed; only
  the parent window on our own origin is heard or answered.
  `?author=` names comment authors unless the user chose a name.
- Test: host_dirty_hears_when_unsaved_work_appears_and_goes.

Without `?host=parent` nothing changes.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Print: a scaled annotation appearance no longer prints larger than its rectangle (#802) (#861)

* Single instance: enable tab handoff in Windows portable mode (#889) (#891)

* Single instance: enable tab handoff in Windows portable mode (#889)

* Single instance: a portable copy's socket stays in %LOCALAPPDATA%, under its own name

(cherry picked from commit c7db1e80a374e80a11504efefbaac680dfa5e872)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Issue #390: build the DMG with hdiutil create so the app passes codesign --strict (#823)

* Issue #390: build the DMG with hdiutil create so the app passes codesign --strict

makehybrid -hfs wrote a com.apple.FinderInfo (Finder location -1,-1) on every
file in the image, and codesign --verify --deep --strict rejects that as
"Finder information, or similar detritus", on the app in the DMG and on a copy
installed from it. hdiutil create -srcfolder writes the files as they are; it
attaches a device while copying, so it gets up to five tries. A gate mounts
the finished image read-only and verifies the app inside with --strict.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

* Issue #390: retry hdiutil attach and detach in the DMG gate too

As suggested in review: the gate mounts the signed image under set -e, so a
"Resource busy" there would stop a release after the DMG was built. The same
five tries with growing pauses now cover create, attach and detach.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

---------

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* M4: make page-label generation lazy across large documents (#845)

* M4: make page-label generation lazy across large documents

Page labels were expanded into one String per page, and the whole sequence was refused once the labels passed 4 MiB in total. A valid document with long labels could therefore neither be read nor edited. Add a PageLabels view over the label ranges: label(page) builds one label, and iter() builds the labels one at a time, so no string is kept per page. Edits validate each range from its largest number (Roman numerals by their thousands) instead of building every label, so the check no longer depends on the page count.

The per-label length limit, the number-tree work and depth limits and the overflow checks are unchanged. page_labels() keeps its signature and returns the same labels as before for ordinary documents.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* M4: drop the aggregate page-label cap from document inspection

Document inspection refused every custom label once their total passed 4 MiB, so a valid document with long labels showed physical page numbers and a warning. Labels are now applied to every page. The per-label length limit, the number-tree work, depth and cycle limits and the prefix bounds are unchanged, and labels are still applied only after every range is accepted.

MAX_LABEL_TOTAL_BYTES is removed. The inspector keeps one label per page (PageInfo.label), so its label memory grows with the page count times the label length. The page count is bounded by the objects in the file, and each label by 1,024 bytes.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* M4: keep the inspector's 4 MiB page-label cap until its labels are lazy

Commit a096ece removed the aggregate page-label cap from document inspection. Inspector::fill keeps one label String per page on open, and inspection does not read labels from the lazy PageLabels view yet, so a hostile file with a million labelled pages would allocate on the order of a gigabyte when it is opened (measured here at about 1.8 KiB per labelled page). This reverts that commit. The cap stays until inspection reads labels lazily, as a separate change. The organizer change in 5253124 does not depend on it.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* lopdf: bound object lookups through compressed containers and object-stream members (#771)

A compressed object's container was resolved with a fresh cycle set and
without checking that it is an ordinary object, and indirect /Length
chains had no depth limit, so self-containing or chained lookups
overflowed the stack. Pass the caller's set on, require an in-file
container and cap the chain at 16 lookups. Object-stream members were
each parsed to the end of the decoded stream, so repeated or nested
offsets kept many copies of one large object: parse each member within
its own extent and skip repeated offsets.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* hayro-syntax: cap the nesting of direct arrays and dictionaries (#772)

Skipping a direct array or dictionary, and the lenient repair of a
dictionary that has an object where a key belongs, recursed once per
level with no bound, so deep nesting in a trailer entry or a content
stream overflowed the stack. Count the depth on the Reader and fail
beyond 128 levels like any other malformed object.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M11: Reduce file size shrinks office scans, and never saves a bigger copy (refs #490) (#849)

Office scanners write the default decode array (/Decode [0 1 0 1 0 1]) on their page
images. Reduce skipped every image with a decode array, so a scan stayed the same
size. The default array is now treated as absent; any other array still skips the
image.

Reduce also saved its copy even when it wasn't smaller, reported as "-N% smaller".
It now saves nothing and says the file is already as small as it can be made;
doc_reduce returns "written": false. Optimize PDF still saves (what it discards
may matter more than the size) but says "not smaller".

Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M11: Reduce file size and Optimize PDF drop images nothing draws, such as a deleted image (#894)

Deleting an image with Edit a PDF removes its Do operation, but the image stays in the
page's /Resources /XObject, so every full rewrite (Reduce file size, Optimize PDF)
carried its data along: a page left blank still weighed the whole picture.

Clean Up now drops image and form XObjects from a page's own resources when their name
is written in no stream the document still uses. Shared and inherited resources are left
alone, and if any stream can't be read the way a viewer would read it, nothing is removed:
viewers look names up through parent resources, so only a name written nowhere is safe to
drop. doc_optimize reports unused_xobjects.

Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* M9: name the Windows store certificates that can't sign (#179) (#761)

A store certificate PdfCraft can't use (no private key, a P-521 /
Brainpool / Ed25519 key, a key CNG can't open) was dropped from the
Sign dialog silently, so a user whose CA-issued identity shows in
Acrobat but not here had nothing to go on. `windows::list` now returns
those under `unusable` with the subject, fingerprint and a reason that
names the Windows error code; the Sign dialog shows them greyed and
unselectable with the reason, and `sign_windows_ids` returns them.
A key that needs a prompt to open (NTE_SILENT_CONTEXT, NTE_UI_REQUIRED
while listing) is listed and prompts at signing instead of being
skipped. Certificates without a private key stay out of the dialog but
are reported by the tool.

Tests: the explained-error table, the store listing's invariants, a
key-less certificate in the ignored store test, the automation output,
and a kittest dialog test for a greyed entry. Verified by hand that a
.p12 imported into the CNG and legacy Microsoft providers lists, and
that a P-521 certificate is explained.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M12: XFA rows in areas get their instances, and areas stay out of data paths (#853)

A Designer form whose table (a top-to-bottom subform inside an area of a
positioned page) asks for several rows with `<occur min="10"/>` was laid
out with one row, and a FormCalc total looping over the rows stayed
empty.

- layout: a repeating subform inside a top-to-bottom container in
  positioned content is drawn and measured once per instance (occur and
  data), with its own SOM index, as flowed content already was. One
  `instances` helper now serves flowing, table rows and this case.
- data: areas are named in SOM expressions but are not data scopes.
  `data_path(tpl, som)` leaves them out (unknown segments are kept), and
  layout, the script tree, reading and writing the datasets and the
  engine's value and row operations use it. Before, values Adobe's
  viewers saved were not read, what was filled here went under a new
  element named after the area (which Adobe's viewers would most likely
  not read), and scripts counted the rows there: one, so a loop over
  the table's rows failed on the missing ones.
- write_datasets_with / read_values_with take the parsed template when
  the caller has it (the engine's cached one); the old functions parse
  the document's.
- Tests: layout regression, data_path rules, and an engine round trip
  shaped like such a form (rows, Adobe-written values, a FormCalc total
  over every row, the data written back, reopening).

Signed-off-by: Hadi Chokr <hadichokr@icloud.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Exported images keep their transparency, JPEG cut-outs included (#645) (#763)

* M10: exported images keep their transparency, JPEG cut-outs included (#645)

Export all images and Save image as wrote a JPEG with a soft mask as the
bare JPEG and dropped soft masks of another size, JPEG-compressed soft
masks, stencil /Mask images and colour-key /Mask arrays, so cut-out
images came out on a solid white or black background.

- An image with transparency (/SMask, stencil /Mask, colour-key /Mask) is
  written as a PNG with it as alpha, JPEG images included (decoded with
  the image crate's JPEG decoder, already in the tree). JPEGs without
  transparency are still written byte for byte.
- Soft and stencil masks of another size are scaled to the image's size;
  /Decode inversion is honoured and /Matte pre-blending is undone.
- Image sizes are checked (positive, at most 100 Mpx) before use, so a
  negative /Width no longer overflows in Save image as.
- Regression tests: JPEG + soft mask, scaled soft mask, stencil mask,
  colour key, matte, and damaged masks/sizes (all fail or panic on main).

Fixes #645

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: use as_chunks for colour-key pairs (clippy 1.99 chunks_exact_to_as_chunks) (#645)

Rust 1.99's clippy denies chunks_exact with a constant size, which failed
the Clippy step on all CI targets. as_chunks::<2> also drops the indexing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Fill colour for rectangle, oval and polygon comments (#686) (#793)

* M5: fill colour for rectangle, oval and polygon comments (#686)

Rectangles, ovals, polygons and clouds could only be given a border
colour. They can now be filled, or have their fill removed:

- the comment quick bar has a Fill colour control for those tools
  ("No fill" or a swatch), kept with the other per-tool styles;
- Comment Properties > Appearance has a Fill colour row, applied in the
  same undo step as the other changes and carried over by Make Current
  Properties Default;
- comment_edit takes `fill` (a colour, or "none").

pdfcraft-annot gains `set_fill` (writes or removes /IC and redraws the
appearance; lines, ink and locked comments refuse it) and reports
`fillable`/`fill` in the comment properties. The engine adds
Edit::FillAnnotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019XAJjko29kzKj7ULXoXSnN

* M5: update ATTRIBUTION.toml hashes for the edited i18n catalogs (#686)

The fill-colour strings changed 13 translation catalogs, so
xtask's the_real_manifest_passes failed on ubuntu and windows CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M14: localize displayed shortcut key names (#496) (#884)

Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M3, #740: column select (Alt/Option-drag, or Edit ▸ Column select) (#765)

As in Acrobat, holding Alt (Option on macOS) when a drag starts selects only
the text inside the rectangle drawn, instead of everything between two
points in reading order. It works with the Select tool, the text markup
tools and Redact, so a column can be copied, highlighted or redacted.

- render: PageText::glyphs_in (glyph centre inside the rectangle),
  glyph_rects (one box per run, never covering unselected text) and
  column_text: rows top to bottom, side-by-side pieces separated by a tab,
  right to left in right-to-left rows, reading order for vertical text.
- ui-egui: a selection is a reading-order run or a column rectangle; the
  rectangle is outlined while dragging and the cursor is a crosshair.
  ⇧-click clears a column selection (it has no anchor glyph).
- Edit ▸ Column select (edit.column_select, quick tool "column-select")
  does it with a plain drag. It is the way in on Linux desktops whose
  window manager takes Alt-drag for moving windows (Xfce, Cinnamon, MATE,
  KDE Plasma 5 by default), where the app never sees the drag.
- automation: text_extract takes rect for the same extraction headless.
- "Column select" translated in all 15 catalogs (Arabic included); their ATTRIBUTION.toml
  hashes updated.
- parity: new view.column-select entry with platform notes and gaps
  (no selection across pages, no ⇧-click extension).

Windows: winit hands an Alt release to the system menu loop only when the
window has a native menu, which PdfCraft's does not, so Alt-drag cannot
leave the window in menu mode.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Combine files: a thumbnail grid, card sizes and card actions (refs #611) (#777)

Combine files shows each file as a card (the default; Grid/List kept in
the settings, --combine-view): the first page it adds, stacked when it
adds more, its name shortened in the middle. Cards select (click, Ctrl,
Shift, arrows by card and by row), drag to reorder (one undo step; the
grid scrolls at its edges) and share the list's toolbar, selection and
history. Locked, unreadable, bad-range and unmeasurable files say why.

Thumbnails render off the UI thread, only for cards in view: at most
three render threads at once, counted until they exit (no watchdog
replacements), a 32 MB texture budget, keys that never show another
page, password or file, and one heavy step per frame on the web.

Cards are sized from 60% to 200% (toolbar slider and buttons, Ctrl
+/-/0, Ctrl+wheel or pinch over the grid, kept in the settings,
--combine-zoom), keeping the card under the pointer in place. Hovering
a card shows its actions over the top of its page: remove that file,
and a magnifier that shows it large, flipping through the pages it
adds; the same on a right click, and Space by keyboard. The toolbar
wraps between groups at narrow widths.

RenderPool gains retire(), without_replacements() and threads() so a
caller can bound the render threads that exist at once.

Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Combine files: expand a file into its pages (2 of 3, refs #611) (#788)

* Combine files: a thumbnail grid, card sizes and card actions (refs #611)

Combine files shows each file as a card (the default; Grid/List kept in
the settings, --combine-view): the first page it adds, stacked when it
adds more, its name shortened in the middle. Cards select (click, Ctrl,
Shift, arrows by card and by row), drag to reorder (one undo step; the
grid scrolls at its edges) and share the list's toolbar, selection and
history. Locked, unreadable, bad-range and unmeasurable files say why.

Thumbnails render off the UI thread, only for cards in view: at most
three render threads at once, counted until they exit (no watchdog
replacements), a 32 MB texture budget, keys that never show another
page, password or file, and one heavy step per frame on the web.

Cards are sized from 60% to 200% (toolbar slider and buttons, Ctrl
+/-/0, Ctrl+wheel or pinch over the grid, kept in the settings,
--combine-zoom), keeping the card under the pointer in place. Hovering
a card shows its actions over the top of its page: remove that file,
and a magnifier that shows it large, flipping through the pages it
adds; the same on a right click, and Space by keyboard. The toolbar
wraps between groups at narrow widths.

RenderPool gains retire(), without_replacements() and threads() so a
caller can bound the render threads that exist at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Combine files: expand a file into its pages (refs #611)

A file that adds more than one page (up to 2000) expands into a card
per page it adds, in its range's order, each labelled with its page and
the file's name, on a shared tinted band: from its card's hover bar, a
right click, or Expand all / Collapse all in the toolbar. A page card's
magnifier opens the preview on that page; Up and Down step rows of
cards. Expanding is a view: not an undo step, kept across views and
zoom, and the combined file is the same either way.

Thumbnails are now kept per file and page, looked up on demand (the
cost follows the files, not the pages of an expanded file), and a
render's document stays open for two seconds so the next page of the
same file isn't read again.

Adds the Lucide minimize-2 icon (ISC, recorded in ATTRIBUTION).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#873)

Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, PDF-spec, hardware, UI and localization parity docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Measure: snap to the implicit closing edge of filled paths (#803) (#862)

* fix(organize): reject rotation angles that are not multiples of 90 (#801) (#833)

Page rotation only turns in quarter steps, but the degree value was read with an integer division by 90 that threw the remainder away. Asking for 45 degrees saved an unrotated page, 91 and 135 saved a 90 degree turn, and the command still reported success.

Reject the value before touching the document instead, so every caller gets an actionable error and the pages are left alone. The automation tool already rejected it; the single edit command did not.

Fixes #801.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* Autosave: snapshot a new unsaved document before its first edit (#815) (#850)

* OCR: Recognize text in multiple files refuses paths outside the root (#795)

ocr_recognize_files turned a path outside the automation root into a per-file
error and returned success, so no_tool_escapes_the_root reported 9 leaks once
the OCR models were installed. Without them, loading the models failed first
and hid it, and CI never installs them. Every path is now checked before any
work starts, and one outside the root refuses the whole call, like the other
tools. A missing file inside the root stays a per-file error. Nothing was ever
read from outside the root.

ocr_recognize_files_refuses_paths_outside_the_root checks this without the
models.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(text): match phrases across repeated whitespace (#814) (#831)

The phrase search collapsed runs of whitespace in the query but not in the page text it was matching against, so a document drawn with two space glyphs in a row like the issue example never matched the phrase. Single words still matched, which is why it looked like only some files were affected.

Collapse whitespace while the text is flattened, the same way the query is normalised. The regression test fails on the old code because the phrase find returns an empty range, and it also covers tabs and single word queries.

Fixes #814.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* fix(edit): keep a comment whose appearance is missing when flattening (#805) (#838)

Viewers draw notes, FreeText, ink and stamps from their dictionary when the file stores no /AP /N. flatten only drew annotations that already had an appearance and then removed the rest, so a supported note disappeared and the saved page came back blank.

Build the appearance a viewer would show for every selected, visible, non-widget annotation that has none, then draw it as usual. A subtype this build cannot render still fails to build and is handled exactly as before.

The new synthetic test draws the note icon into the page and asserts it is baked in; without the fix flatten reports 0 drawn and the icon is lost.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* fix(comments): keep a reply to a reply in the comment listing (#806) (#839)

comment_list collected only the replies whose /IRT pointed at the root comment, so a reply to a reply was listed nowhere at all. Roots and the root count were already right; the nested reply was simply dropped from its thread.

Follow the /IRT chain to any depth, with a bound, so every reply under a comment is listed in document order. A reply to a reply now stays discoverable through the tool that promises every comment and its replies.

The new test builds a root note, a direct reply and a reply to that reply, then checks both texts are listed and the two notes stay roots. It listed only the direct reply before the fix.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* Issue #774: keep the catalog out of object streams in encrypted files (Acrobat) (#836)

Acrobat reports an encrypted PDF as "damaged and could not be repaired", before
asking for the password, when the document catalog sits in an object stream.
Every other reader tried (qpdf, pypdf, poppler, PDFKit, PdfCraft) accepts such
files, and qpdf keeps the catalog standalone too. Full saves of encrypted
documents now write the catalog as its own object; unencrypted output is
unchanged. Verified with Acrobat DC on macOS: the same file fails with the
catalog packed and opens with it standalone, in AES-256 and RC4-128.


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

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(edit): refuse a header start number that would wrap the next page (#804) (#834)

Page numbering adds the start number to the position of the page in the run. At the top of the u32 range that addition wrapped, so a release build saved zero for the second page and still reported success, and a debug build hit a caught overflow error instead.

Check the whole run before drawing anything. When the last page number would pass the u32 limit the operation returns an actionable error and the document keeps no header, rather than a half applied run.

Fixes #804.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* OCR: a scanned euro sign reads as €, not E (#796)

The ocrs crate's default alphabet has an E where its recognition model was
trained with € (ocrs-models DEFAULT_ALPHABET), so a scanned "€250" was
recognised as "E250". pdfcraft-ocr now gives ocrs the alphabet the model was
trained with.

the_alphabet_is_the_trained_one checks the alphabet without the models;
recognize_text_reads_the_euro_sign reads a scan end to end when they are
installed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(comments): include replies to replies in the comment summary (#820) (#840)

The comment summary gathered a comment's direct replies only, matching /IRT against the comment itself. A reply to a reply points at the direct reply, so its text never reached the generated summary even though the stored discussion kept it.

Replies are now walked to any depth, with a bound, and indented one level per reply. The existing output for direct replies is unchanged.

The new test builds a rectangle, a reply and a reply to that reply, then checks the summary carries both texts with the nested one indented under its parent. It omitted the nested reply before the fix.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* fix(xfdf): drop the replaced note's popup when re-importing a comment (#807) (#860)

* fix(view): check Shift-Command-G before Command-G for find navigation (#810) (#835)

* view: check Shift-Command-G before Command-G for find navigation (#810)

* Tests: the find shortcut test waits for find by a deadline

(cherry picked from commit eaf3180bd8ebb353b3d8c5ca7b9ef7548ed55c37)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* fix(editing): recognize paragraph drafts as unsaved work (#811) (#843)

* view: check Shift-Command-G before Command-G for find navigation (#810)

* fix: recognize paragraph drafts as unsaved work (Issue #811)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* fix(automation): retain batch results with identical filenames (#877)

* fix(automation): retain batch results with identical filenames

* Format

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M13: preserve desktop Action Wizard batch results (#886)

* M13: preserve desktop Action Wizard batch results

Co-Authored-By: Codex <noreply@openai.com>

* ROADMAP: date the desktop action outputs entry today

---------

Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Fit to width fits the shown page in single-page view (#808) (#859)

* fix(links): find URLs when lowercasing changes the text length (#816) (#830)

find_urls case-folded a copy of the page text and then sliced the original
characters with offsets taken from that copy. Case folding can change a
string's length — U+0130 (İ) lowercases to "i" plus a combining dot — so the
offsets drifted past the end of the source and `chars[i..j]` panicked. The CLI
exited 101 before it could create the link; MCP reported an internal tool error
but kept serving.

Match the ASCII prefixes case-insensitively against the original characters
instead, so every offset stays in the source's own index space, and use get()
so no lookup can index past the end.

A new test reproduces the reported panic on the old code (range start index 41
out of range for slice of length 39) and passes with the fix. pdfcraft-annot's
tests, clippy and rustfmt are green.

Fixes #816.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* organize: deleting the opening page drops the catalog OpenAction (#821) (#855)

* fix(text): Turkish dotted and dotless i match in case-insensitive search (#892)

Case-insensitive search folded the query and the page with
str::to_lowercase, which follows no locale: U+0130 (İ) becomes "i" plus a
combining dot above (two characters) and U+0131 (ı) has no case pair. So
"istanbul" never found "İSTANBUL", and "ırmak"/"kış" never found
"IRMAK"/"KIŞ" (or the reverse). The same PageText::find serves Find,
Search & Redact (phrases and word lists) and the automation text search,
so Turkish words could also be left unredacted.

Fold I, i, İ and ı to "i" and every other character with its own
lowercase, one character for one character. Case-sensitive search is
unchanged.

* XFDF keeps polygon and polyline vertices (#809) (#848)

Export wrote polygon and polyline comments without their points and import
had nowhere to read them from, so an XFDF round trip left the comments with
no /Vertices and no appearance. Export now writes the points as XFDF
<vertices>x,y;x,y;…</vertices>, and import reads them back (whole x,y pairs
only) before the appearance is drawn.

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* XFDF keeps a callout's leader, text-box inset, intent and arrowhead (#819) (#854)

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Web: warn before reloading a document with unsaved changes (#812) (#856)

* Issue #813: a cancelled recovery password no longer attaches to the next unlocked document (#858)

Recovering an encrypted snapshot parked its metadata in `pending_recovered`
until the password prompt was answered. Cancel left it there, and the next
encrypted document unlocked in the foreground took the snapshot's original
path and recovery key: Save would overwrite the other file and delete its
snapshot.

The association now travels with the prompt (`PasswordPrompt::recovered`).
Cancel puts the entry back on offer and leaves the store alone; a wrong
password keeps it on the re-raised prompt; the correct one finishes the
recovery of the tab that was just opened. `pending_recovered` is gone.

Test: crash with an encrypted snapshot of A, Recover, Cancel, open encrypted
B with its password. B keeps its own path and is clean, A's snapshot stays
in the store, also after closing B. Fails on main at "B keeps its own path".

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* Viewer: touchpad scrolling tracks the finger 1:1 (#759) (#868)

A precision touchpad sends pixel-precise `MouseWheel` events. On Windows
they arrive with no `TouchPhase::Start`, so egui never takes its
no-smoothing "in touch" path and eases any delta of 8 px or more over
several frames, leaving the page a frame behind the finger.

The page view now applies precise (Point-unit) wheel input itself, 1:1,
through the same un-eased scroll path as keyboard and middle-button
auto-scroll, and suppresses egui's eased copy for a short window so its
internal easing tail neither doubles nor trails the motion. A coarse
mouse wheel (Line/Page units) keeps egui's smoothing, which is pleasant
for chunky notches, and single-page paging still owns the vertical axis.

Regression test: one 200 px precise swipe scrolls the full 200 px at
once (an eased path would ramp there over ~0.1 s) and leaves no drift.

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Keep every part when bookmark titles repeat in doc_split (#800) (#863)

* Windows: add reproducible security diagnostics for storytold/pdfcraft#747 (#769)

Add packaging/windows/security-audit.ps1, a read-only maintainer helper that records a
release file's SHA-256, Authenticode status and, with an external YARA binary and rule,
every matched string identifier and offset. Document the procedure in docs/releasing.md
("Scanner detections").

No application, dependency, installer or network behaviour changes: the diff touches only
the helper and the release documentation.

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* Notice bar: the message wraps beside its buttons and lines up with them (#785) (#832)

The notice bar above the pages (security, forms, XFA, repaired file,
warnings, signatures) laid its message out before its buttons. In a
narrow document area (a side panel open, a longer translation) the
message ran under "Security settings" and on into the side panel, and it
wasn't centred on the buttons.

A shared notice_bar helper now lays the buttons out first, right to
left, centred on a 28 px row, and gives the message the width that's
left: it wraps there (cut short with the full text on hover when less
than 120 px is left) and its first line is centred on the buttons' row.

Regression test: editing::the_security_notice_wraps_beside_its_buttons_and_lines_up_with_them
(one line, wrapped and cut-short widths; fails on main).

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* M10.2: scan JPEG metadata beyond frame headers (#764)

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Issue #878: Combine stores an image once when its colour space is an indirect name (#883)

ImageMagick writes an image's colour space as its own object (`/ColorSpace 10 0 R`,
`10 0 obj /DeviceRGB`). Each source's copy of that object was never merged, since it
isn't a resource, so identical images compared unequal and every copy was kept:
8 copies of an 80 MB ImageMagick PDF combined into 648 MB instead of 81 MB.

Dedupe now compares a reference to a plain value (name, number, boolean or string) as
that value. The value objects themselves are still never merged. Combine, Insert Pages
and Reduce File Size share this code.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* organize: drop named destinations and their links when a page is deleted (#817) (#851)

* JavaScript: limit util.printf widths and output, and array arguments (#770)

util.printf accumulated a conversion's width and precision with unchecked
arithmetic and no maximum, and its output had no bound; the array
arguments of resetForm and the field value/colour setters were read for
as many items as the array's length says, which a sparse array makes
free. Saturate the numbers and refuse widths and precisions above 4096,
output above 1 MiB and arrays longer than 2^20 with a script error,
before anything is allocated from them.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Linux: "Use system setting" asks the desktop, not winit (#881)

winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is
`None` there on every desktop — so `ThemePreference::System` never saw the
desktop's choice and silently fell back to whatever was on screen: PdfCraft
came up light on a dark desktop, and picking "Use system setting" after Dark
kept it dark. The default setting did not follow the system.

`pdfcraft-platform` now reads the XDG Desktop Portal's Settings interface off
the UI thread and the app follows the answer, including a change made while it
runs. The colour scheme is asked for under its GNOME schema name first, which
GNOME's and XFCE's portals both answer, then under the older freedesktop one,
which KDE answers. A desktop that answers neither leaves the user's own choice
alone, exactly as before.

Co-authored-by: GyuriX <gyurix@users.noreply.github.com>

* Extract/Combine: a page repeated in one selection gets its own annotations (#818) (#852)

* M5.7: Fill & Sign tools pick up placed signatures to move and resize them (#822)

A signature placed with Fill & Sign could only be moved with the Select
tool. While a Fill & Sign tool was active (Sign, Initials, Add text,
Check, Date...), hovering the signature showed the placement crosshair,
a drag did nothing and a click stamped another mark on top of it.

Now, as in Acrobat, a Fill & Sign tool picks up the Fill & Sign marks
already on the page: hovering one shows the move cursor, a click selects
it, a drag moves it and the selected mark's handles resize it. Away from
them the tool places as before, including on other comments. Image
signatures keep their live move/resize preview.

Comments now carry a fill_sign flag (pdfcraft_annot::is_fill_sign for
the engine's list, the same rules in the viewer's inspector), so this
also works on reopened files.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Web: opt-in embedding bridge for host pages (?host=parent) (#842)

A page that frames PdfCraft on the same origin can now pass it PDFs and
receive saves through postMessage, instead of Save becoming a browser
download. Built for a Nextcloud integration, but nothing in it is
host-specific.

- ui-egui: HostSaveFn/HostDirtyFn hooks (`host_save`, `host_dirty`),
  None by default. On wasm, Save hands the bytes to `host_save` when
  set; `host_dirty` hears each frame whether any tab has unsaved work.
- pdfcraft-web: `host::Bridge`, enabled only by `?host=parent` inside a
  frame. Messages: pdfcraft:ready / open / save / dirty / failed; only
  the parent window on our own origin is heard or answered.
  `?author=` names comment authors unless the user chose a name.
- Test: host_dirty_hears_when_unsaved_work_appears_and_goes.

Without `?host=parent` nothing changes.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Print: a scaled annotation appearance no longer prints larger than its rectangle (#802) (#861)

* Single instance: enable tab handoff in Windows portable mode (#889) (#891)

* Single instance: enable tab handoff in Windows portable mode (#889)

* Single instance: a portable copy's socket stays in %LOCALAPPDATA%, under its own name

(cherry picked from commit c7db1e80a374e80a11504efefbaac680dfa5e872)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Issue #390: build the DMG with hdiutil create so the app passes codesign --strict (#823)

* Issue #390: build the DMG with hdiutil create so the app passes codesign --strict

makehybrid -hfs wrote a com.apple.FinderInfo (Finder location -1,-1) on every
file in the image, and codesign --verify --deep --strict rejects that as
"Finder information, or similar detritus", on the app in the DMG and on a copy
installed from it. hdiutil create -srcfolder writes the files as they are; it
attaches a device while copying, so it gets up to five tries. A gate mounts
the finished image read-only and verifies the app inside with --strict.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

* Issue #390: retry hdiutil attach and detach in the DMG gate too

As suggested in review: the gate mounts the signed image under set -e, so a
"Resource busy" there would stop a release after the DMG was built. The same
five tries with growing pauses now cover create, attach and detach.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

---------

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* M4: make page-label generation lazy across large documents (#845)

* M4: make page-label generation lazy across large documents

Page labels were expanded into one String per page, and the whole sequence was refused once the labels passed 4 MiB in total. A valid document with long labels could therefore neither be read nor edited. Add a PageLabels view over the label ranges: label(page) builds one label, and iter() builds the labels one at a time, so no string is kept per page. Edits validate each range from its largest number (Roman numerals by their thousands) instead of building every label, so the check no longer depends on the page count.

The per-label length limit, the number-tree work and depth limits and the overflow checks are unchanged. page_labels() keeps its signature and returns the same labels as before for ordinary documents.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* M4: drop the aggregate page-label cap from document inspection

Document inspection refused every custom label once their total passed 4 MiB, so a valid document with long labels showed physical page numbers and a warning. Labels are now applied to every page. The per-label length limit, the number-tree work, depth and cycle limits and the prefix bounds are unchanged, and labels are still applied only after every range is accepted.

MAX_LABEL_TOTAL_BYTES is removed. The inspector keeps one label per page (PageInfo.label), so its label memory grows with the page count times the label length. The page count is bounded by the objects in the file, and each label by 1,024 bytes.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* M4: keep the inspector's 4 MiB page-label cap until its labels are lazy

Commit a096ece removed the aggregate page-label cap from document inspection. Inspector::fill keeps one label String per page on open, and inspection does not read labels from the lazy PageLabels view yet, so a hostile file with a million labelled pages would allocate on the order of a gigabyte when it is opened (measured here at about 1.8 KiB per labelled page). This reverts that commit. The cap stays until inspection reads labels lazily, as a separate change. The organizer change in 5253124 does not depend on it.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* lopdf: bound object lookups through compressed containers and object-stream members (#771)

A compressed object's container was resolved with a fresh cycle set and
without checking that it is an ordinary object, and indirect /Length
chains had no depth limit, so self-containing or chained lookups
overflowed the stack. Pass the caller's set on, require an in-file
container and cap the chain at 16 lookups. Object-stream members were
each parsed to the end of the decoded stream, so repeated or nested
offsets kept many copies of one large object: parse each member within
its own extent and skip repeated offsets.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* hayro-syntax: cap the nesting of direct arrays and dictionaries (#772)

Skipping a direct array or dictionary, and the lenient repair of a
dictionary that has an object where a key belongs, recursed once per
level with no bound, so deep nesting in a trailer entry or a content
stream overflowed the stack. Count the depth on the Reader and fail
beyond 128 levels like any other malformed object.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M11: Reduce file size shrinks office scans, and never saves a bigger copy (refs #490) (#849)

Office scanners write the default decode array (/Decode [0 1 0 1 0 1]) on their page
images. Reduce skipped every image with a decode array, so a scan stayed the same
size. The default array is now treated as absent; any other array still skips the
image.

Reduce also saved its copy even when it wasn't smaller, reported as "-N% smaller".
It now saves nothing and says the file is already as small as it can be made;
doc_reduce returns "written": false. Optimize PDF still saves (what it discards
may matter more than the size) but says "not smaller".

Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M11: Reduce file size and Optimize PDF drop images nothing draws, such as a deleted image (#894)

Deleting an image with Edit a PDF removes its Do operation, but the image stays in the
page's /Resources /XObject, so every full rewrite (Reduce file size, Optimize PDF)
carried its data along: a page left blank still weighed the whole picture.

Clean Up now drops image and form XObjects from a page's own resources when their name
is written in no stream the document still uses. Shared and inherited resources are left
alone, and if any stream can't be read the way a viewer would read it, nothing is removed:
viewers look names up through parent resources, so only a name written nowhere is safe to
drop. doc_optimize reports unused_xobjects.

Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* M9: name the Windows store certificates that can't sign (#179) (#761)

A store certificate PdfCraft can't use (no private key, a P-521 /
Brainpool / Ed25519 key, a key CNG can't open) was dropped from the
Sign dialog silently, so a user whose CA-issued identity shows in
Acrobat but not here had nothing to go on. `windows::list` now returns
those under `unusable` with the subject, fingerprint and a reason that
names the Windows error code; the Sign dialog shows them greyed and
unselectable with the reason, and `sign_windows_ids` returns them.
A key that needs a prompt to open (NTE_SILENT_CONTEXT, NTE_UI_REQUIRED
while listing) is listed and prompts at signing instead of being
skipped. Certificates without a private key stay out of the dialog but
are reported by the tool.

Tests: the explained-error table, the store listing's invariants, a
key-less certificate in the ignored store test, the automation output,
and a kittest dialog test for a greyed entry. Verified by hand that a
.p12 imported into the CNG and legacy Microsoft providers lists, and
that a P-521 certificate is explained.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M12: XFA rows in areas get their instances, and areas stay out of data paths (#853)

A Designer form whose table (a top-to-bottom subform inside an area of a
positioned page) asks for several rows with `<occur min="10"/>` was laid
out with one row, and a FormCalc total looping over the rows stayed
empty.

- layout: a repeating subform inside a top-to-bottom container in
  positioned content is drawn and measured once per instance (occur and
  data), with its own SOM index, as flowed content already was. One
  `instances` helper now serves flowing, table rows and this case.
- data: areas are named in SOM expressions but are not data scopes.
  `data_path(tpl, som)` leaves them out (unknown segments are kept), and
  layout, the script tree, reading and writing the datasets and the
  engine's value and row operations use it. Before, values Adobe's
  viewers saved were not read, what was filled here went under a new
  element named after the area (which Adobe's viewers would most likely
  not read), and scripts counted the rows there: one, so a loop over
  the table's rows failed on the missing ones.
- write_datasets_with / read_values_with take the parsed template when
  the caller has it (the engine's cached one); the old functions parse
  the document's.
- Tests: layout regression, data_path rules, and an engine round trip
  shaped like such a form (rows, Adobe-written values, a FormCalc total
  over every row, the data written back, reopening).

Signed-off-by: Hadi Chokr <hadichokr@icloud.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Exported images keep their transparency, JPEG cut-outs included (#645) (#763)

* M10: exported images keep their transparency, JPEG cut-outs included (#645)

Export all images and Save image as wrote a JPEG with a soft mask as the
bare JPEG and dropped soft masks of another size, JPEG-compressed soft
masks, stencil /Mask images and colour-key /Mask arrays, so cut-out
images came out on a solid white or black background.

- An image with transparency (/SMask, stencil /Mask, colour-key /Mask) is
  written as a PNG with it as alpha, JPEG images included (decoded with
  the image crate's JPEG decoder, already in the tree). JPEGs without
  transparency are still written byte for byte.
- Soft and stencil masks of another size are scaled to the image's size;
  /Decode inversion is honoured and /Matte pre-blending is undone.
- Image sizes are checked (positive, at most 100 Mpx) before use, so a
  negative /Width no longer overflows in Save image as.
- Regression tests: JPEG + soft mask, scaled soft mask, stencil mask,
  colour key, matte, and damaged masks/sizes (all fail or panic on main).

Fixes #645

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: use as_chunks for colour-key pairs (clippy 1.99 chunks_exact_to_as_chunks) (#645)

Rust 1.99's clippy denies chunks_exact with a constant size, which failed
the Clippy step on all CI targets. as_chunks::<2> also drops the indexing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Fill colour for rectangle, oval and polygon comments (#686) (#793)

* M5: fill colour for rectangle, oval and polygon comments (#686)

Rectangles, ovals, polygons and clouds could only be given a border
colour. They can now be filled, or have their fill removed:

- the comment quick bar has a Fill colour control for those tools
  ("No fill" or a swatch), kept with the other per-tool styles;
- Comment Properties > Appearance has a Fill colour row, applied in the
  same undo step as the other changes and carried over by Make Current
  Properties Default;
- comment_edit takes `fill` (a colour, or "none").

pdfcraft-annot gains `set_fill` (writes or removes /IC and redraws the
appearance; lines, ink and locked comments refuse it) and reports
`fillable`/`fill` in the comment properties. The engine adds
Edit::FillAnnotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019XAJjko29kzKj7ULXoXSnN

* M5: update ATTRIBUTION.toml hashes for the edited i18n catalogs (#686)

The fill-colour strings changed 13 translation catalogs, so
xtask's the_real_manifest_passes failed on ubuntu and windows CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M14: localize displayed shortcut key names (#496) (#884)

Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M3, #740: column select (Alt/Option-drag, or Edit ▸ Column select) (#765)

As in Acrobat, holding Alt (Option on macOS) when a drag starts selects only
the text inside the rectangle drawn, instead of everything between two
points in reading order. It works with the Select tool, the text markup
tools and Redact, so a column can be copied, highlighted or redacted.

- render: PageText::glyphs_in (glyph centre inside the rectangle),
  glyph_rects (one box per run, never covering unselected text) and
  column_text: rows top to bottom, side-by-side pieces separated by a tab,
  right to left in right-to-left rows, reading order for vertical text.
- ui-egui: a selection is a reading-order run or a column rectangle; the
  rectangle is outlined while dragging and the cursor is a crosshair.
  ⇧-click clears a column selection (it has no anchor glyph).
- Edit ▸ Column select (edit.column_select, quick tool "column-select")
  does it with a plain drag. It is the way in on Linux desktops whose
  window manager takes Alt-drag for moving windows (Xfce, Cinnamon, MATE,
  KDE Plasma 5 by default), where the app never sees the drag.
- automation: text_extract takes rect for the same extraction headless.
- "Column select" translated in all 15 catalogs (Arabic included); their ATTRIBUTION.toml
  hashes updated.
- parity: new view.column-select entry with platform notes and gaps
  (no selection across pages, no ⇧-click extension).

Windows: winit hands an Alt release to the system menu loop only when the
window has a native menu, which PdfCraft's does not, so Alt-drag cannot
leave the window in menu mode.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Combine files: a thumbnail grid, card sizes and card actions (refs #611) (#777)

Combine files shows each file as a card (the default; Grid/List kept in
the settings, --combine-view): the first page it adds, stacked when it
adds more, its name shortened in the middle. Cards select (click, Ctrl,
Shift, arrows by card and by row), drag to reorder (one undo step; the
grid scrolls at its edges) and share the list's toolbar, selection and
history. Locked, unreadable, bad-range and unmeasurable files say why.

Thumbnails render off the UI thread, only for cards in view: at most
three render threads at once, counted until they exit (no watchdog
replacements), a 32 MB texture budget, keys that never show another
page, password or file, and one heavy step per frame on the web.

Cards are sized from 60% to 200% (toolbar slider and buttons, Ctrl
+/-/0, Ctrl+wheel or pinch over the grid, kept in the settings,
--combine-zoom), keeping the card under the pointer in place. Hovering
a card shows its actions over the top of its page: remove that file,
and a magnifier that shows it large, flipping through the pages it
adds; the same on a right click, and Space by keyboard. The toolbar
wraps between groups at narrow widths.

RenderPool gains retire(), without_replacements() and threads() so a
caller can bound the render threads that exist at once.

Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Combine files: expand a file into its pages (refs #611)

A file that adds more than one page (up to 2000) expands into a card
per page it adds, in its range's order, each labelled with its page and
the file's name, on a shared tinted band: from its card's hover bar, a
right click, or Expand all / Collapse all in the toolbar. A page card's
magnifier opens the preview on that page; Up and Down step rows of
cards. Expanding is a view: not an undo step, kept across views and
zoom, and the combined file is the same either way.

Thumbnails are now kept per file and page, looked up on demand (the
cost follows the files, not the pages of an expanded file), and a
render's document stays open for two seconds so the next page of the
same file isn't read again.

Adds the Lucide minimize-2 icon (ISC, recorded in ATTRIBUTION).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: Hadi Chokr <hadichokr@icloud.com>
Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
Co-authored-by: muraa-p <85794781+muraa-p@users.noreply.github.com>
Co-authored-by: Big Pickle <noreply@opencode.ai>
Co-authored-by: mlkonrad <marlonk@gmail.com>
Co-authored-by: Tristan von Kries <tristanvonkries@gmail.com>
Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Krzysztof Cieślik <132496025+Hathor875@users.noreply.github.com>
Co-authored-by: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com>
Co-authored-by: Tevinhead <barrett.wallace@gmail.com>
Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: snipeTR <31975916+snipeTR@users.noreply.github.com>
Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Justin John <34035011+justinjohn0306@users.noreply.github.com>
Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: cyfung1031 <44498510+cyfung1031@users.noreply.github.com>
Co-authored-by: 才倾梦华V <185041488+CQMHV@users.noreply.github.com>
Co-authored-by: Ikki Nishihara <b25d025@akita-pu.ac.jp>
Co-authored-by: gyurix <barathgy@gmail.com>
Co-authored-by: GyuriX <gyurix@users.noreply.github.com>
Co-authored-by: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com>
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: CyberFox-81 <brittondcouchman@gmail.com>
Co-authored-by: Aviral Bansal <94526754+A-viral-dev@users.noreply.github.com>
Co-authored-by: Epsilon-LLC <64039063+EpsilonLLC@users.noreply.github.com>
Co-authored-by: Silver <hadichokr@icloud.com>

* Combine files: move and remove single pages before combining (closes #611)

With a file expanded, single pages are selected (click, Ctrl, Shift),
dragged anywhere and removed (a page card's trash, the toolbar, Delete),
each one undo step; the files themselves are never changed. A page moved
between another file's pages splits its file into parts that share its
bytes: the list shows each part as a row with its own page range, and
parts that end up side by side join again. A split file still counts,
unlocks and combines as one file.

organize gains combine_grouped (and Session::combine_grouped): each file
is copied once and its pages put in the order shown, so it keeps one
bookmark at its first page, its nested bookmarks, links between its own
pages, its fields and one copy of its attachments. A page listed twice
now gets its own copies of its annotations, and a form field's widget on
it joins the same field, so the field keeps one value. The existing
combine is unchanged; doc_combine gains an optional groups
array (entries with the same number are one file split into parts), so
the same combine is reachable headless.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Combine: a repeated widget joins its field's /Kids array object too

* Organize tests: a repeated widget that is its own field stays one object (#790)

---------

Signed-off-by: Hadi Chokr <hadichokr@icloud.com>
Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
Co-authored-by: muraa-p <85794781+muraa-p@users.noreply.github.com>
Co-authored-by: Big Pickle <noreply@opencode.ai>
Co-authored-by: mlkonrad <marlonk@gmail.com>
Co-authored-by: Tristan von Kries <tristanvonkries@gmail.com>
Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Krzysztof Cieślik <132496025+Hathor875@users.noreply.github.com>
Co-authored-by: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com>
Co-authored-by: Tevinhead <barrett.wallace@gmail.com>
Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: snipeTR <31975916+snipeTR@users.noreply.github.com>
Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Justin John <34035011+justinjohn0306@users.noreply.github.com>
Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: cyfung1031 <44498510+cyfung1031@users.noreply.github.com>
Co-authored-by: 才倾梦华V <185041488+CQMHV@users.noreply.github.com>
Co-authored-by: Ikki Nishihara <b25d025@akita-pu.ac.jp>
Co-authored-by: gyurix <barathgy@gmail.com>
Co-authored-by: GyuriX <gyurix@users.noreply.github.com>
Co-authored-by: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com>
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: CyberFox-81 <brittondcouchman@gmail.com>
Co-authored-by: Aviral Bansal <94526754+A-viral-dev@users.noreply.github.com>
Co-authored-by: Epsilon-LLC <64039063+EpsilonLLC@users.noreply.github.com>
Co-authored-by: Silver <hadichokr@icloud.com>
2026-10-10 17:01:08 -04:00
+21 26dec1030b Combine files: expand a file into its pages (2 of 3, refs #611) (#788)
* Combine files: a thumbnail grid, card sizes and card actions (refs #611)

Combine files shows each file as a card (the default; Grid/List kept in
the settings, --combine-view): the first page it adds, stacked when it
adds more, its name shortened in the middle. Cards select (click, Ctrl,
Shift, arrows by card and by row), drag to reorder (one undo step; the
grid scrolls at its edges) and share the list's toolbar, selection and
history. Locked, unreadable, bad-range and unmeasurable files say why.

Thumbnails render off the UI thread, only for cards in view: at most
three render threads at once, counted until they exit (no watchdog
replacements), a 32 MB texture budget, keys that never show another
page, password or file, and one heavy step per frame on the web.

Cards are sized from 60% to 200% (toolbar slider and buttons, Ctrl
+/-/0, Ctrl+wheel or pinch over the grid, kept in the settings,
--combine-zoom), keeping the card under the pointer in place. Hovering
a card shows its actions over the top of its page: remove that file,
and a magnifier that shows it large, flipping through the pages it
adds; the same on a right click, and Space by keyboard. The toolbar
wraps between groups at narrow widths.

RenderPool gains retire(), without_replacements() and threads() so a
caller can bound the render threads that exist at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Combine files: expand a file into its pages (refs #611)

A file that adds more than one page (up to 2000) expands into a card
per page it adds, in its range's order, each labelled with its page and
the file's name, on a shared tinted band: from its card's hover bar, a
right click, or Expand all / Collapse all in the toolbar. A page card's
magnifier opens the preview on that page; Up and Down step rows of
cards. Expanding is a view: not an undo step, kept across views and
zoom, and the combined file is the same either way.

Thumbnails are now kept per file and page, looked up on demand (the
cost follows the files, not the pages of an expanded file), and a
render's document stays open for two seconds so the next page of the
same file isn't read again.

Adds the Lucide minimize-2 icon (ISC, recorded in ATTRIBUTION).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#873)

Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, PDF-spec, hardware, UI and localization parity docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Measure: snap to the implicit closing edge of filled paths (#803) (#862)

* fix(organize): reject rotation angles that are not multiples of 90 (#801) (#833)

Page rotation only turns in quarter steps, but the degree value was read with an integer division by 90 that threw the remainder away. Asking for 45 degrees saved an unrotated page, 91 and 135 saved a 90 degree turn, and the command still reported success.

Reject the value before touching the document instead, so every caller gets an actionable error and the pages are left alone. The automation tool already rejected it; the single edit command did not.

Fixes #801.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* Autosave: snapshot a new unsaved document before its first edit (#815) (#850)

* OCR: Recognize text in multiple files refuses paths outside the root (#795)

ocr_recognize_files turned a path outside the automation root into a per-file
error and returned success, so no_tool_escapes_the_root reported 9 leaks once
the OCR models were installed. Without them, loading the models failed first
and hid it, and CI never installs them. Every path is now checked before any
work starts, and one outside the root refuses the whole call, like the other
tools. A missing file inside the root stays a per-file error. Nothing was ever
read from outside the root.

ocr_recognize_files_refuses_paths_outside_the_root checks this without the
models.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(text): match phrases across repeated whitespace (#814) (#831)

The phrase search collapsed runs of whitespace in the query but not in the page text it was matching against, so a document drawn with two space glyphs in a row like the issue example never matched the phrase. Single words still matched, which is why it looked like only some files were affected.

Collapse whitespace while the text is flattened, the same way the query is normalised. The regression test fails on the old code because the phrase find returns an empty range, and it also covers tabs and single word queries.

Fixes #814.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* fix(edit): keep a comment whose appearance is missing when flattening (#805) (#838)

Viewers draw notes, FreeText, ink and stamps from their dictionary when the file stores no /AP /N. flatten only drew annotations that already had an appearance and then removed the rest, so a supported note disappeared and the saved page came back blank.

Build the appearance a viewer would show for every selected, visible, non-widget annotation that has none, then draw it as usual. A subtype this build cannot render still fails to build and is handled exactly as before.

The new synthetic test draws the note icon into the page and asserts it is baked in; without the fix flatten reports 0 drawn and the icon is lost.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* fix(comments): keep a reply to a reply in the comment listing (#806) (#839)

comment_list collected only the replies whose /IRT pointed at the root comment, so a reply to a reply was listed nowhere at all. Roots and the root count were already right; the nested reply was simply dropped from its thread.

Follow the /IRT chain to any depth, with a bound, so every reply under a comment is listed in document order. A reply to a reply now stays discoverable through the tool that promises every comment and its replies.

The new test builds a root note, a direct reply and a reply to that reply, then checks both texts are listed and the two notes stay roots. It listed only the direct reply before the fix.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* Issue #774: keep the catalog out of object streams in encrypted files (Acrobat) (#836)

Acrobat reports an encrypted PDF as "damaged and could not be repaired", before
asking for the password, when the document catalog sits in an object stream.
Every other reader tried (qpdf, pypdf, poppler, PDFKit, PdfCraft) accepts such
files, and qpdf keeps the catalog standalone too. Full saves of encrypted
documents now write the catalog as its own object; unencrypted output is
unchanged. Verified with Acrobat DC on macOS: the same file fails with the
catalog packed and opens with it standalone, in AES-256 and RC4-128.


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

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(edit): refuse a header start number that would wrap the next page (#804) (#834)

Page numbering adds the start number to the position of the page in the run. At the top of the u32 range that addition wrapped, so a release build saved zero for the second page and still reported success, and a debug build hit a caught overflow error instead.

Check the whole run before drawing anything. When the last page number would pass the u32 limit the operation returns an actionable error and the document keeps no header, rather than a half applied run.

Fixes #804.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* OCR: a scanned euro sign reads as €, not E (#796)

The ocrs crate's default alphabet has an E where its recognition model was
trained with € (ocrs-models DEFAULT_ALPHABET), so a scanned "€250" was
recognised as "E250". pdfcraft-ocr now gives ocrs the alphabet the model was
trained with.

the_alphabet_is_the_trained_one checks the alphabet without the models;
recognize_text_reads_the_euro_sign reads a scan end to end when they are
installed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(comments): include replies to replies in the comment summary (#820) (#840)

The comment summary gathered a comment's direct replies only, matching /IRT against the comment itself. A reply to a reply points at the direct reply, so its text never reached the generated summary even though the stored discussion kept it.

Replies are now walked to any depth, with a bound, and indented one level per reply. The existing output for direct replies is unchanged.

The new test builds a rectangle, a reply and a reply to that reply, then checks the summary carries both texts with the nested one indented under its parent. It omitted the nested reply before the fix.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* fix(xfdf): drop the replaced note's popup when re-importing a comment (#807) (#860)

* fix(view): check Shift-Command-G before Command-G for find navigation (#810) (#835)

* view: check Shift-Command-G before Command-G for find navigation (#810)

* Tests: the find shortcut test waits for find by a deadline

(cherry picked from commit eaf3180bd8ebb353b3d8c5ca7b9ef7548ed55c37)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* fix(editing): recognize paragraph drafts as unsaved work (#811) (#843)

* view: check Shift-Command-G before Command-G for find navigation (#810)

* fix: recognize paragraph drafts as unsaved work (Issue #811)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* fix(automation): retain batch results with identical filenames (#877)

* fix(automation): retain batch results with identical filenames

* Format

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M13: preserve desktop Action Wizard batch results (#886)

* M13: preserve desktop Action Wizard batch results

Co-Authored-By: Codex <noreply@openai.com>

* ROADMAP: date the desktop action outputs entry today

---------

Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Fit to width fits the shown page in single-page view (#808) (#859)

* fix(links): find URLs when lowercasing changes the text length (#816) (#830)

find_urls case-folded a copy of the page text and then sliced the original
characters with offsets taken from that copy. Case folding can change a
string's length — U+0130 (İ) lowercases to "i" plus a combining dot — so the
offsets drifted past the end of the source and `chars[i..j]` panicked. The CLI
exited 101 before it could create the link; MCP reported an internal tool error
but kept serving.

Match the ASCII prefixes case-insensitively against the original characters
instead, so every offset stays in the source's own index space, and use get()
so no lookup can index past the end.

A new test reproduces the reported panic on the old code (range start index 41
out of range for slice of length 39) and passes with the fix. pdfcraft-annot's
tests, clippy and rustfmt are green.

Fixes #816.

Co-authored-by: Big Pickle <noreply@opencode.ai>

* organize: deleting the opening page drops the catalog OpenAction (#821) (#855)

* fix(text): Turkish dotted and dotless i match in case-insensitive search (#892)

Case-insensitive search folded the query and the page with
str::to_lowercase, which follows no locale: U+0130 (İ) becomes "i" plus a
combining dot above (two characters) and U+0131 (ı) has no case pair. So
"istanbul" never found "İSTANBUL", and "ırmak"/"kış" never found
"IRMAK"/"KIŞ" (or the reverse). The same PageText::find serves Find,
Search & Redact (phrases and word lists) and the automation text search,
so Turkish words could also be left unredacted.

Fold I, i, İ and ı to "i" and every other character with its own
lowercase, one character for one character. Case-sensitive search is
unchanged.

* XFDF keeps polygon and polyline vertices (#809) (#848)

Export wrote polygon and polyline comments without their points and import
had nowhere to read them from, so an XFDF round trip left the comments with
no /Vertices and no appearance. Export now writes the points as XFDF
<vertices>x,y;x,y;…</vertices>, and import reads them back (whole x,y pairs
only) before the appearance is drawn.

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* XFDF keeps a callout's leader, text-box inset, intent and arrowhead (#819) (#854)

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Web: warn before reloading a document with unsaved changes (#812) (#856)

* Issue #813: a cancelled recovery password no longer attaches to the next unlocked document (#858)

Recovering an encrypted snapshot parked its metadata in `pending_recovered`
until the password prompt was answered. Cancel left it there, and the next
encrypted document unlocked in the foreground took the snapshot's original
path and recovery key: Save would overwrite the other file and delete its
snapshot.

The association now travels with the prompt (`PasswordPrompt::recovered`).
Cancel puts the entry back on offer and leaves the store alone; a wrong
password keeps it on the re-raised prompt; the correct one finishes the
recovery of the tab that was just opened. `pending_recovered` is gone.

Test: crash with an encrypted snapshot of A, Recover, Cancel, open encrypted
B with its password. B keeps its own path and is clean, A's snapshot stays
in the store, also after closing B. Fails on main at "B keeps its own path".

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* Viewer: touchpad scrolling tracks the finger 1:1 (#759) (#868)

A precision touchpad sends pixel-precise `MouseWheel` events. On Windows
they arrive with no `TouchPhase::Start`, so egui never takes its
no-smoothing "in touch" path and eases any delta of 8 px or more over
several frames, leaving the page a frame behind the finger.

The page view now applies precise (Point-unit) wheel input itself, 1:1,
through the same un-eased scroll path as keyboard and middle-button
auto-scroll, and suppresses egui's eased copy for a short window so its
internal easing tail neither doubles nor trails the motion. A coarse
mouse wheel (Line/Page units) keeps egui's smoothing, which is pleasant
for chunky notches, and single-page paging still owns the vertical axis.

Regression test: one 200 px precise swipe scrolls the full 200 px at
once (an eased path would ramp there over ~0.1 s) and leaves no drift.

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Keep every part when bookmark titles repeat in doc_split (#800) (#863)

* Windows: add reproducible security diagnostics for storytold/pdfcraft#747 (#769)

Add packaging/windows/security-audit.ps1, a read-only maintainer helper that records a
release file's SHA-256, Authenticode status and, with an external YARA binary and rule,
every matched string identifier and offset. Document the procedure in docs/releasing.md
("Scanner detections").

No application, dependency, installer or network behaviour changes: the diff touches only
the helper and the release documentation.

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* Notice bar: the message wraps beside its buttons and lines up with them (#785) (#832)

The notice bar above the pages (security, forms, XFA, repaired file,
warnings, signatures) laid its message out before its buttons. In a
narrow document area (a side panel open, a longer translation) the
message ran under "Security settings" and on into the side panel, and it
wasn't centred on the buttons.

A shared notice_bar helper now lays the buttons out first, right to
left, centred on a 28 px row, and gives the message the width that's
left: it wraps there (cut short with the full text on hover when less
than 120 px is left) and its first line is centred on the buttons' row.

Regression test: editing::the_security_notice_wraps_beside_its_buttons_and_lines_up_with_them
(one line, wrapped and cut-short widths; fails on main).

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* M10.2: scan JPEG metadata beyond frame headers (#764)

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Issue #878: Combine stores an image once when its colour space is an indirect name (#883)

ImageMagick writes an image's colour space as its own object (`/ColorSpace 10 0 R`,
`10 0 obj /DeviceRGB`). Each source's copy of that object was never merged, since it
isn't a resource, so identical images compared unequal and every copy was kept:
8 copies of an 80 MB ImageMagick PDF combined into 648 MB instead of 81 MB.

Dedupe now compares a reference to a plain value (name, number, boolean or string) as
that value. The value objects themselves are still never merged. Combine, Insert Pages
and Reduce File Size share this code.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* organize: drop named destinations and their links when a page is deleted (#817) (#851)

* JavaScript: limit util.printf widths and output, and array arguments (#770)

util.printf accumulated a conversion's width and precision with unchecked
arithmetic and no maximum, and its output had no bound; the array
arguments of resetForm and the field value/colour setters were read for
as many items as the array's length says, which a sparse array makes
free. Saturate the numbers and refuse widths and precisions above 4096,
output above 1 MiB and arrays longer than 2^20 with a script error,
before anything is allocated from them.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Linux: "Use system setting" asks the desktop, not winit (#881)

winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is
`None` there on every desktop — so `ThemePreference::System` never saw the
desktop's choice and silently fell back to whatever was on screen: PdfCraft
came up light on a dark desktop, and picking "Use system setting" after Dark
kept it dark. The default setting did not follow the system.

`pdfcraft-platform` now reads the XDG Desktop Portal's Settings interface off
the UI thread and the app follows the answer, including a change made while it
runs. The colour scheme is asked for under its GNOME schema name first, which
GNOME's and XFCE's portals both answer, then under the older freedesktop one,
which KDE answers. A desktop that answers neither leaves the user's own choice
alone, exactly as before.

Co-authored-by: GyuriX <gyurix@users.noreply.github.com>

* Extract/Combine: a page repeated in one selection gets its own annotations (#818) (#852)

* M5.7: Fill & Sign tools pick up placed signatures to move and resize them (#822)

A signature placed with Fill & Sign could only be moved with the Select
tool. While a Fill & Sign tool was active (Sign, Initials, Add text,
Check, Date...), hovering the signature showed the placement crosshair,
a drag did nothing and a click stamped another mark on top of it.

Now, as in Acrobat, a Fill & Sign tool picks up the Fill & Sign marks
already on the page: hovering one shows the move cursor, a click selects
it, a drag moves it and the selected mark's handles resize it. Away from
them the tool places as before, including on other comments. Image
signatures keep their live move/resize preview.

Comments now carry a fill_sign flag (pdfcraft_annot::is_fill_sign for
the engine's list, the same rules in the viewer's inspector), so this
also works on reopened files.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Web: opt-in embedding bridge for host pages (?host=parent) (#842)

A page that frames PdfCraft on the same origin can now pass it PDFs and
receive saves through postMessage, instead of Save becoming a browser
download. Built for a Nextcloud integration, but nothing in it is
host-specific.

- ui-egui: HostSaveFn/HostDirtyFn hooks (`host_save`, `host_dirty`),
  None by default. On wasm, Save hands the bytes to `host_save` when
  set; `host_dirty` hears each frame whether any tab has unsaved work.
- pdfcraft-web: `host::Bridge`, enabled only by `?host=parent` inside a
  frame. Messages: pdfcraft:ready / open / save / dirty / failed; only
  the parent window on our own origin is heard or answered.
  `?author=` names comment authors unless the user chose a name.
- Test: host_dirty_hears_when_unsaved_work_appears_and_goes.

Without `?host=parent` nothing changes.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Print: a scaled annotation appearance no longer prints larger than its rectangle (#802) (#861)

* Single instance: enable tab handoff in Windows portable mode (#889) (#891)

* Single instance: enable tab handoff in Windows portable mode (#889)

* Single instance: a portable copy's socket stays in %LOCALAPPDATA%, under its own name

(cherry picked from commit c7db1e80a374e80a11504efefbaac680dfa5e872)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Issue #390: build the DMG with hdiutil create so the app passes codesign --strict (#823)

* Issue #390: build the DMG with hdiutil create so the app passes codesign --strict

makehybrid -hfs wrote a com.apple.FinderInfo (Finder location -1,-1) on every
file in the image, and codesign --verify --deep --strict rejects that as
"Finder information, or similar detritus", on the app in the DMG and on a copy
installed from it. hdiutil create -srcfolder writes the files as they are; it
attaches a device while copying, so it gets up to five tries. A gate mounts
the finished image read-only and verifies the app inside with --strict.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

* Issue #390: retry hdiutil attach and detach in the DMG gate too

As suggested in review: the gate mounts the signed image under set -e, so a
"Resource busy" there would stop a release after the DMG was built. The same
five tries with growing pauses now cover create, attach and detach.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

---------

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* M4: make page-label generation lazy across large documents (#845)

* M4: make page-label generation lazy across large documents

Page labels were expanded into one String per page, and the whole sequence was refused once the labels passed 4 MiB in total. A valid document with long labels could therefore neither be read nor edited. Add a PageLabels view over the label ranges: label(page) builds one label, and iter() builds the labels one at a time, so no string is kept per page. Edits validate each range from its largest number (Roman numerals by their thousands) instead of building every label, so the check no longer depends on the page count.

The per-label length limit, the number-tree work and depth limits and the overflow checks are unchanged. page_labels() keeps its signature and returns the same labels as before for ordinary documents.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* M4: drop the aggregate page-label cap from document inspection

Document inspection refused every custom label once their total passed 4 MiB, so a valid document with long labels showed physical page numbers and a warning. Labels are now applied to every page. The per-label length limit, the number-tree work, depth and cycle limits and the prefix bounds are unchanged, and labels are still applied only after every range is accepted.

MAX_LABEL_TOTAL_BYTES is removed. The inspector keeps one label per page (PageInfo.label), so its label memory grows with the page count times the label length. The page count is bounded by the objects in the file, and each label by 1,024 bytes.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* M4: keep the inspector's 4 MiB page-label cap until its labels are lazy

Commit a096ece removed the aggregate page-label cap from document inspection. Inspector::fill keeps one label String per page on open, and inspection does not read labels from the lazy PageLabels view yet, so a hostile file with a million labelled pages would allocate on the order of a gigabyte when it is opened (measured here at about 1.8 KiB per labelled page). This reverts that commit. The cap stays until inspection reads labels lazily, as a separate change. The organizer change in 5253124 does not depend on it.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* lopdf: bound object lookups through compressed containers and object-stream members (#771)

A compressed object's container was resolved with a fresh cycle set and
without checking that it is an ordinary object, and indirect /Length
chains had no depth limit, so self-containing or chained lookups
overflowed the stack. Pass the caller's set on, require an in-file
container and cap the chain at 16 lookups. Object-stream members were
each parsed to the end of the decoded stream, so repeated or nested
offsets kept many copies of one large object: parse each member within
its own extent and skip repeated offsets.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* hayro-syntax: cap the nesting of direct arrays and dictionaries (#772)

Skipping a direct array or dictionary, and the lenient repair of a
dictionary that has an object where a key belongs, recursed once per
level with no bound, so deep nesting in a trailer entry or a content
stream overflowed the stack. Count the depth on the Reader and fail
beyond 128 levels like any other malformed object.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M11: Reduce file size shrinks office scans, and never saves a bigger copy (refs #490) (#849)

Office scanners write the default decode array (/Decode [0 1 0 1 0 1]) on their page
images. Reduce skipped every image with a decode array, so a scan stayed the same
size. The default array is now treated as absent; any other array still skips the
image.

Reduce also saved its copy even when it wasn't smaller, reported as "-N% smaller".
It now saves nothing and says the file is already as small as it can be made;
doc_reduce returns "written": false. Optimize PDF still saves (what it discards
may matter more than the size) but says "not smaller".

Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M11: Reduce file size and Optimize PDF drop images nothing draws, such as a deleted image (#894)

Deleting an image with Edit a PDF removes its Do operation, but the image stays in the
page's /Resources /XObject, so every full rewrite (Reduce file size, Optimize PDF)
carried its data along: a page left blank still weighed the whole picture.

Clean Up now drops image and form XObjects from a page's own resources when their name
is written in no stream the document still uses. Shared and inherited resources are left
alone, and if any stream can't be read the way a viewer would read it, nothing is removed:
viewers look names up through parent resources, so only a name written nowhere is safe to
drop. doc_optimize reports unused_xobjects.

Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* M9: name the Windows store certificates that can't sign (#179) (#761)

A store certificate PdfCraft can't use (no private key, a P-521 /
Brainpool / Ed25519 key, a key CNG can't open) was dropped from the
Sign dialog silently, so a user whose CA-issued identity shows in
Acrobat but not here had nothing to go on. `windows::list` now returns
those under `unusable` with the subject, fingerprint and a reason that
names the Windows error code; the Sign dialog shows them greyed and
unselectable with the reason, and `sign_windows_ids` returns them.
A key that needs a prompt to open (NTE_SILENT_CONTEXT, NTE_UI_REQUIRED
while listing) is listed and prompts at signing instead of being
skipped. Certificates without a private key stay out of the dialog but
are reported by the tool.

Tests: the explained-error table, the store listing's invariants, a
key-less certificate in the ignored store test, the automation output,
and a kittest dialog test for a greyed entry. Verified by hand that a
.p12 imported into the CNG and legacy Microsoft providers lists, and
that a P-521 certificate is explained.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M12: XFA rows in areas get their instances, and areas stay out of data paths (#853)

A Designer form whose table (a top-to-bottom subform inside an area of a
positioned page) asks for several rows with `<occur min="10"/>` was laid
out with one row, and a FormCalc total looping over the rows stayed
empty.

- layout: a repeating subform inside a top-to-bottom container in
  positioned content is drawn and measured once per instance (occur and
  data), with its own SOM index, as flowed content already was. One
  `instances` helper now serves flowing, table rows and this case.
- data: areas are named in SOM expressions but are not data scopes.
  `data_path(tpl, som)` leaves them out (unknown segments are kept), and
  layout, the script tree, reading and writing the datasets and the
  engine's value and row operations use it. Before, values Adobe's
  viewers saved were not read, what was filled here went under a new
  element named after the area (which Adobe's viewers would most likely
  not read), and scripts counted the rows there: one, so a loop over
  the table's rows failed on the missing ones.
- write_datasets_with / read_values_with take the parsed template when
  the caller has it (the engine's cached one); the old functions parse
  the document's.
- Tests: layout regression, data_path rules, and an engine round trip
  shaped like such a form (rows, Adobe-written values, a FormCalc total
  over every row, the data written back, reopening).

Signed-off-by: Hadi Chokr <hadichokr@icloud.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Exported images keep their transparency, JPEG cut-outs included (#645) (#763)

* M10: exported images keep their transparency, JPEG cut-outs included (#645)

Export all images and Save image as wrote a JPEG with a soft mask as the
bare JPEG and dropped soft masks of another size, JPEG-compressed soft
masks, stencil /Mask images and colour-key /Mask arrays, so cut-out
images came out on a solid white or black background.

- An image with transparency (/SMask, stencil /Mask, colour-key /Mask) is
  written as a PNG with it as alpha, JPEG images included (decoded with
  the image crate's JPEG decoder, already in the tree). JPEGs without
  transparency are still written byte for byte.
- Soft and stencil masks of another size are scaled to the image's size;
  /Decode inversion is honoured and /Matte pre-blending is undone.
- Image sizes are checked (positive, at most 100 Mpx) before use, so a
  negative /Width no longer overflows in Save image as.
- Regression tests: JPEG + soft mask, scaled soft mask, stencil mask,
  colour key, matte, and damaged masks/sizes (all fail or panic on main).

Fixes #645

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: use as_chunks for colour-key pairs (clippy 1.99 chunks_exact_to_as_chunks) (#645)

Rust 1.99's clippy denies chunks_exact with a constant size, which failed
the Clippy step on all CI targets. as_chunks::<2> also drops the indexing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Fill colour for rectangle, oval and polygon comments (#686) (#793)

* M5: fill colour for rectangle, oval and polygon comments (#686)

Rectangles, ovals, polygons and clouds could only be given a border
colour. They can now be filled, or have their fill removed:

- the comment quick bar has a Fill colour control for those tools
  ("No fill" or a swatch), kept with the other per-tool styles;
- Comment Properties > Appearance has a Fill colour row, applied in the
  same undo step as the other changes and carried over by Make Current
  Properties Default;
- comment_edit takes `fill` (a colour, or "none").

pdfcraft-annot gains `set_fill` (writes or removes /IC and redraws the
appearance; lines, ink and locked comments refuse it) and reports
`fillable`/`fill` in the comment properties. The engine adds
Edit::FillAnnotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019XAJjko29kzKj7ULXoXSnN

* M5: update ATTRIBUTION.toml hashes for the edited i18n catalogs (#686)

The fill-colour strings changed 13 translation catalogs, so
xtask's the_real_manifest_passes failed on ubuntu and windows CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M14: localize displayed shortcut key names (#496) (#884)

Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M3, #740: column select (Alt/Option-drag, or Edit ▸ Column select) (#765)

As in Acrobat, holding Alt (Option on macOS) when a drag starts selects only
the text inside the rectangle drawn, instead of everything between two
points in reading order. It works with the Select tool, the text markup
tools and Redact, so a column can be copied, highlighted or redacted.

- render: PageText::glyphs_in (glyph centre inside the rectangle),
  glyph_rects (one box per run, never covering unselected text) and
  column_text: rows top to bottom, side-by-side pieces separated by a tab,
  right to left in right-to-left rows, reading order for vertical text.
- ui-egui: a selection is a reading-order run or a column rectangle; the
  rectangle is outlined while dragging and the cursor is a crosshair.
  ⇧-click clears a column selection (it has no anchor glyph).
- Edit ▸ Column select (edit.column_select, quick tool "column-select")
  does it with a plain drag. It is the way in on Linux desktops whose
  window manager takes Alt-drag for moving windows (Xfce, Cinnamon, MATE,
  KDE Plasma 5 by default), where the app never sees the drag.
- automation: text_extract takes rect for the same extraction headless.
- "Column select" translated in all 15 catalogs (Arabic included); their ATTRIBUTION.toml
  hashes updated.
- parity: new view.column-select entry with platform notes and gaps
  (no selection across pages, no ⇧-click extension).

Windows: winit hands an Alt release to the system menu loop only when the
window has a native menu, which PdfCraft's does not, so Alt-drag cannot
leave the window in menu mode.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Combine files: a thumbnail grid, card sizes and card actions (refs #611) (#777)

Combine files shows each file as a card (the default; Grid/List kept in
the settings, --combine-view): the first page it adds, stacked when it
adds more, its name shortened in the middle. Cards select (click, Ctrl,
Shift, arrows by card and by row), drag to reorder (one undo step; the
grid scrolls at its edges) and share the list's toolbar, selection and
history. Locked, unreadable, bad-range and unmeasurable files say why.

Thumbnails render off the UI thread, only for cards in view: at most
three render threads at once, counted until they exit (no watchdog
replacements), a 32 MB texture budget, keys that never show another
page, password or file, and one heavy step per frame on the web.

Cards are sized from 60% to 200% (toolbar slider and buttons, Ctrl
+/-/0, Ctrl+wheel or pinch over the grid, kept in the settings,
--combine-zoom), keeping the card under the pointer in place. Hovering
a card shows its actions over the top of its page: remove that file,
and a magnifier that shows it large, flipping through the pages it
adds; the same on a right click, and Space by keyboard. The toolbar
wraps between groups at narrow widths.

RenderPool gains retire(), without_replacements() and threads() so a
caller can bound the render threads that exist at once.

Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Combine files: expand a file into its pages (refs #611)

A file that adds more than one page (up to 2000) expands into a card
per page it adds, in its range's order, each labelled with its page and
the file's name, on a shared tinted band: from its card's hover bar, a
right click, or Expand all / Collapse all in the toolbar. A page card's
magnifier opens the preview on that page; Up and Down step rows of
cards. Expanding is a view: not an undo step, kept across views and
zoom, and the combined file is the same either way.

Thumbnails are now kept per file and page, looked up on demand (the
cost follows the files, not the pages of an expanded file), and a
render's document stays open for two seconds so the next page of the
same file isn't read again.

Adds the Lucide minimize-2 icon (ISC, recorded in ATTRIBUTION).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: Hadi Chokr <hadichokr@icloud.com>
Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
Co-authored-by: muraa-p <85794781+muraa-p@users.noreply.github.com>
Co-authored-by: Big Pickle <noreply@opencode.ai>
Co-authored-by: mlkonrad <marlonk@gmail.com>
Co-authored-by: Tristan von Kries <tristanvonkries@gmail.com>
Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Krzysztof Cieślik <132496025+Hathor875@users.noreply.github.com>
Co-authored-by: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com>
Co-authored-by: Tevinhead <barrett.wallace@gmail.com>
Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: snipeTR <31975916+snipeTR@users.noreply.github.com>
Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Justin John <34035011+justinjohn0306@users.noreply.github.com>
Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: cyfung1031 <44498510+cyfung1031@users.noreply.github.com>
Co-authored-by: 才倾梦华V <185041488+CQMHV@users.noreply.github.com>
Co-authored-by: Ikki Nishihara <b25d025@akita-pu.ac.jp>
Co-authored-by: gyurix <barathgy@gmail.com>
Co-authored-by: GyuriX <gyurix@users.noreply.github.com>
Co-authored-by: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com>
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: CyberFox-81 <brittondcouchman@gmail.com>
Co-authored-by: Aviral Bansal <94526754+A-viral-dev@users.noreply.github.com>
Co-authored-by: Epsilon-LLC <64039063+EpsilonLLC@users.noreply.github.com>
Co-authored-by: Silver <hadichokr@icloud.com>
2026-10-10 16:50:18 -04:00
5268047a33 Combine files: a thumbnail grid, card sizes and card actions (refs #611) (#777)
Combine files shows each file as a card (the default; Grid/List kept in
the settings, --combine-view): the first page it adds, stacked when it
adds more, its name shortened in the middle. Cards select (click, Ctrl,
Shift, arrows by card and by row), drag to reorder (one undo step; the
grid scrolls at its edges) and share the list's toolbar, selection and
history. Locked, unreadable, bad-range and unmeasurable files say why.

Thumbnails render off the UI thread, only for cards in view: at most
three render threads at once, counted until they exit (no watchdog
replacements), a 32 MB texture budget, keys that never show another
page, password or file, and one heavy step per frame on the web.

Cards are sized from 60% to 200% (toolbar slider and buttons, Ctrl
+/-/0, Ctrl+wheel or pinch over the grid, kept in the settings,
--combine-zoom), keeping the card under the pointer in place. Hovering
a card shows its actions over the top of its page: remove that file,
and a magnifier that shows it large, flipping through the pages it
adds; the same on a right click, and Space by keyboard. The toolbar
wraps between groups at narrow widths.

RenderPool gains retire(), without_replacements() and threads() so a
caller can bound the render threads that exist at once.

Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:44:15 -04:00
65e89aefb2 M3, #740: column select (Alt/Option-drag, or Edit ▸ Column select) (#765)
As in Acrobat, holding Alt (Option on macOS) when a drag starts selects only
the text inside the rectangle drawn, instead of everything between two
points in reading order. It works with the Select tool, the text markup
tools and Redact, so a column can be copied, highlighted or redacted.

- render: PageText::glyphs_in (glyph centre inside the rectangle),
  glyph_rects (one box per run, never covering unselected text) and
  column_text: rows top to bottom, side-by-side pieces separated by a tab,
  right to left in right-to-left rows, reading order for vertical text.
- ui-egui: a selection is a reading-order run or a column rectangle; the
  rectangle is outlined while dragging and the cursor is a crosshair.
  ⇧-click clears a column selection (it has no anchor glyph).
- Edit ▸ Column select (edit.column_select, quick tool "column-select")
  does it with a plain drag. It is the way in on Linux desktops whose
  window manager takes Alt-drag for moving windows (Xfce, Cinnamon, MATE,
  KDE Plasma 5 by default), where the app never sees the drag.
- automation: text_extract takes rect for the same extraction headless.
- "Column select" translated in all 15 catalogs (Arabic included); their ATTRIBUTION.toml
  hashes updated.
- parity: new view.column-select entry with platform notes and gaps
  (no selection across pages, no ⇧-click extension).

Windows: winit hands an Alt release to the system menu loop only when the
window has a native menu, which PdfCraft's does not, so Alt-drag cannot
leave the window in menu mode.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:41:27 -04:00
2a02d5ddab M14: localize displayed shortcut key names (#496) (#884)
Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:39:23 -04:00
86dd8d8283 Fill colour for rectangle, oval and polygon comments (#686) (#793)
* M5: fill colour for rectangle, oval and polygon comments (#686)

Rectangles, ovals, polygons and clouds could only be given a border
colour. They can now be filled, or have their fill removed:

- the comment quick bar has a Fill colour control for those tools
  ("No fill" or a swatch), kept with the other per-tool styles;
- Comment Properties > Appearance has a Fill colour row, applied in the
  same undo step as the other changes and carried over by Make Current
  Properties Default;
- comment_edit takes `fill` (a colour, or "none").

pdfcraft-annot gains `set_fill` (writes or removes /IC and redraws the
appearance; lines, ink and locked comments refuse it) and reports
`fillable`/`fill` in the comment properties. The engine adds
Edit::FillAnnotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019XAJjko29kzKj7ULXoXSnN

* M5: update ATTRIBUTION.toml hashes for the edited i18n catalogs (#686)

The fill-colour strings changed 13 translation catalogs, so
xtask's the_real_manifest_passes failed on ubuntu and windows CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:37:04 -04:00
4fd2c2b3a3 M11: Reduce file size shrinks office scans, and never saves a bigger copy (refs #490) (#849)
Office scanners write the default decode array (/Decode [0 1 0 1 0 1]) on their page
images. Reduce skipped every image with a decode array, so a scan stayed the same
size. The default array is now treated as absent; any other array still skips the
image.

Reduce also saved its copy even when it wasn't smaller, reported as "-N% smaller".
It now saves nothing and says the file is already as small as it can be made;
doc_reduce returns "written": false. Optimize PDF still saves (what it discards
may matter more than the size) but says "not smaller".

Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:20:50 -04:00
9cd07e4793 M8: redact lists of words or phrases (protect.redact-word-lists) (#757)
* M8: redact lists of words or phrases

Find text and redact gains "Multiple words or phrases": a list with one word
or phrase per line, typed or imported from a .txt file (Import list…), whose
every match is marked in one undo step. Lists are trimmed, deduplicated and
bounded (1,000 entries of 256 characters; files up to 1 MB are read).
redact_mark takes words and reports matched_words, the marks each entry made.

protect.redact-word-lists is shipped; whole-word and case-sensitive matching
and exporting the list are still missing.

* M8: translate word-list labels into Arabic

The Arabic catalog arrived on main after this branch, so the new Find
text and redact strings were still English and failed
arabic_covers_ui_literals.

* Tests: drop sharp grid renders when the thumbnail will cover the cell

Zooming the organize grid back out left the sharp page raster in place
until a thumbnail texture existed, so the zoom test failed when that
texture had not arrived yet.

* M8: Czech rows for the word-list redaction labels

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:32:09 -04:00
aa6242d0c6 M4: bookmarks from tagged headings (organize.bookmarks-from-structure) (#754)
* Parity: merge the duplicate form.field-list evidence and notes

#649 and #650 each added `evidence` and `notes` to the same entry, so the
TOML has duplicate keys and `cargo xtask parity` fails on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: bookmarks from tagged headings (organize.bookmarks-from-structure)

Bookmark options > New bookmarks from structure (command
bookmark.from_structure, tool bookmark_from_structure) turns a tagged
document's headings into bookmarks, as Acrobat's New Bookmarks From
Structure does for headings: H1-H6 through the role map, a plain H by its
Sect/Part/Art nesting, nested by level under a new first "Untitled"
bookmark, each linked to its structure element (/SE, ISO 32000-2
§12.3.3). Titles come from /ActualText, the marked text drawn on the page
(decoded through the font's ToUnicode or encoding), /Alt or /T.

The structure walk is bounded (depth, visits, heading and title caps,
seen-set) and survives self-referencing elements; an untagged document
gets an actionable error; the edit is one undo step under the assemble
permission. Not yet: choosing other element types, and destinations at the
heading's position rather than its page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: size the shared raster allowance from thumbnail demand

The canvas test planted a 132×171 thumbnail whose tag did not match the
request finish_render_frame computes. On FreeBSD that real render finished
in the same frame, so the retained raster exceeded 132×171×4. Plant a
thumbnail that already satisfies demand and compare against that size.

* M4: a heading's text stops gathering once it is far past the title length

A heading whose marked-content list repeats one mark up to the visit cap could otherwise collect
about a gigabyte before the title is cut to 256 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 08:25:10 -04:00
1c33e6f851 M14: Arabic interface language (#692)
* M14: Arabic interface language

A 2,136-entry Arabic catalog (six CLDR plural forms, Arabic month and
weekday names), selected by `ar` and every `ar-*` locale. egui joins
Arabic letters but places words left to right, so the catalog is put
into display order when it loads (bidi::display_rtl); placeholders,
keyboard shortcuts and caption colons stay in place. The command
search matches a multi-word Arabic query word by word.

The craft-fonts pin moves to the revision with Noto Sans Arabic.
crates/fonts/build.rs now refuses to embed Adobe type designs, so the
Noto Sans CJK SC in that revision is never embedded (AGENTS.md §1.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M14: Arabic catalog follows the {key} shortcut labels and has Redaction code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:36:54 -04:00
ebc5ccd4c0 M14: View menu, tooltips and Keyboard shortcuts show Ctrl, not ⌘, off macOS (#695)
View ▸ Zoom and Page navigation were hand-written with "⌘1", "⌘[" … while
the registered items below them were formatted by Shortcut::label, so
Linux showed both ⌘1 and Ctrl+K in one menu. Ten tooltips and the view-key
rows of Help ▸ Keyboard shortcuts hard-coded ⌘ the same way.

- The keys the document view handles itself are Shortcut constants in
  ui-egui commands.rs, written with the same Shortcut::label.
- Labels follow egui's detected OS (ctx.os()) instead of cfg!, so the web
  build in a Linux or Windows browser shows Ctrl too, and tests can check
  both platforms.
- Translated tooltips take a {key} placeholder (13 catalogs; only the
  shortcut token changed, hashes updated in ATTRIBUTION.toml).
- Fit visible is listed once in View (in Zoom), not also in the generated
  list; Ctrl+3 and the palette still run it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:32:06 -04:00
bfa65a9bfc M8: redaction code sets (U.S. FOIA, Privacy Act) as overlay text (#741)
* M8: redaction code sets (U.S. FOIA, Privacy Act) as overlay text

Redaction properties offers "Custom text" or "Redaction code" under "Use overlay
text". Picking the U.S. FOIA (5 U.S.C. 552(b)) or U.S. Privacy Act (552a) set and
ticking codes makes new marks carry them as overlay text, in the set's order.
redact_mark takes code_set and codes and rejects unknown sets or codes with the
allowed list.

Also merges a duplicated evidence/notes pair in form.field-list that made
parity/acrobat-features.toml fail to parse on main.

* M8: say why the default redaction code set can't fail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: pin the clock in form_bulk (saves a second apart differed in /ModDate on Windows CI)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:27:26 -04:00
306bbcedf9 M3: harden render and CLI failure surfaces (#669)
* M3: harden render and CLI failure surfaces

Make content truncation observable, reject unknown CLI options, expose deterministic render work counters, and scope render failures to exact requests. Refuse silently reduced whole-page export rasters while preserving tiled rendering, and add heavy-vector and CLI regression coverage.

Co-authored-by: Codex <noreply@openai.com>

* Render: remember a failed render by page and kind again

A key with scale and tag made every zoom level and tile a new key: a
pathological page tied up a worker for the watchdog limit per request
(exhausting worker replacements) and was re-parsed each time. The
repeated-parsing check asks for a new generation and scale again.

* Export: pages too large at the chosen dpi export at the most the renderer allows, and say so

#669 made every Exporter refuse oversized whole-page renders, which
reached File > Export to image, doc_export_* and OCR: at 600 dpi any page
side over 13.65 in (Legal, A3, Tabloid) failed instead of exporting. The
exporter clamps again; Exporter::dpi_used reports the resolution a page
is drawn at, which the Export dialog's message and doc_export_png's
lower_dpi list mention. A source with reject_oversize still refuses
(CLI render keeps its strict config).

* doc_export_images: describe lower_dpi

* Render: ContentTruncated is reported once per page; the hayro-interpret patch is recorded

Nested forms, Type 3 glyphs and patterns are interpreted again and each
reported the exhausted budget; pdfcraft-render now keeps one. The warning
is part of hayro-interpret patch (18) (the page content budget): marked
in the code and described in vendor/README.md.

* CLI render: print render warnings (skipped content) to stderr

* CLI: the option check refuses unknown options only; commands report missing values themselves

text's own --page parsing (and its test on main) says what a page value
must be; the generic "needs a value" pre-empted it.

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:32:48 -04:00
a99b39396f M6: bulk form-field properties and selection (#95) (#650)
* M6: bulk-edit shared form field properties (#95)

Add opt-in shared properties, partial appearance patches and atomic CLI/MCP
batches. Complete current-page, rectangle and Fields-panel selection, with
20 synthetic regressions for preservation, rollback and dialog lifecycle.

Co-Authored-By: Codex <noreply@openai.com>

* Bulk field properties: translated messages, a catalog check, and in-place /DA patches

- The bulk dialog's flag label, errors and notices are in all 13 full catalogs; the missing-field error is a template (fmt + tl!) instead of format!. New test bulk_field_properties_labels_are_in_every_full_catalog.
- patch_da rewrites the last Tf and fill colour in place (appending only when there is none), so /DA doesn't grow per edit; sizes clamp to 300 like parse_da.
- A size- or font-only edit leaves widgets untouched; an indirect /MK or /BS is updated where it lives instead of inlined into each widget.
- Draft::new says why: a widgetless field gets its own message, not 'could not be found'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: pin the session clock in the bulk-fields harness (save_bytes stamps /ModDate)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:56:17 -04:00
Eduardo SoaresandBrandon Thomas 49c6e2ec54 i18n: complete Brazilian Portuguese (pt-BR) interface catalog (#604)
* i18n: complete Brazilian Portuguese (pt-BR) interface catalog

Catalog by André Cardoso (andrecards) from #288, unioned with the
strings merged after the catalog was cut. Asset hash refreshed.

* i18n: translate the pt-BR strings added since the #288 catalog and gate coverage

* i18n: pt-br review fixes (line ending, redact/sanitize/page-boxes terminology)

* i18n: credit the pt-BR translator and document full coverage

Name André Cardoso (andrecards) as the pt-br catalog's translator in
ATTRIBUTION.toml: the ~1,826-entry gist posted in issue #288, with the
labels added since and the wording review as this change's contribution.
ATTRIBUTION.md is regenerated from it.

docs/localization.md moves Brazilian Portuguese from the menus-only
clause into the full-coverage list, states the 1,997-entry count next
to the provenance (following the Ukrainian paragraph's pattern), and
lists pt-br with the languages whose coverage tests gate every
command, All tools label and tl!() literal (added with the catalog).

people.toml is unchanged: PR #165's translator has no entry there, and
its policy is that only one's own entry may be added.

* i18n: pt-br writes '>' like the other Latin catalogs (Inter has no ▸)

* i18n: pt-BR final review fixes (toast grammar, contextual entries, menu gate)

* pt-BR: add the labels main gained while this catalog was completed (98) and drop stale OCR keys

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 20:56:13 -04:00
94f9ce69c6 i18n: add Italian (it) interface translation (#494)
* i18n: add Italian (it) interface translation

- Complete it catalog (about 2,000 entries); it and it_* locales use it
- Fonts test covers the Italian catalog; ▸ written as > like fr/es/ru, since Inter has no ▸
- Docs, attribution and ROADMAP

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* it: add the labels main gained since this catalog (83), drop stale OCR keys, and coverage tests

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 20:55:59 -04:00
4fc99e4845 Tab strip: shrink tabs before scrolling, keep Open in sight; ignore about:blank titles (#612)
* M3: tabs shrink to fit before they scroll, and Open stays in sight

With more documents open than fit, the tabs kept their full width: they ran
under the buttons on the right (#416 made them scroll) and the Open button
scrolled away with them. Now the tabs shrink first, as browsers do: narrow
tabs keep their width, wider ones share the rest equally and end their names
in "…" (the tooltip has the whole name). Only once they are at their narrowest
does the strip scroll. Open sits after the tabs, outside the scrolling area,
so it is always in sight next to the controls on the right.

Refs #397.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: a placeholder title like about:blank leaves the file name on the tab

A browser's "Save as PDF" of a blank page or pop-up titles the document
"about:blank" and asks viewers to show the title, so its tab and window said
"about:blank". A one-word browser address (about:, blob:, data:) or a bare
"Untitled" now counts as no title, and the file name is shown. A tab that
does show a document title names its file on hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: arrows step through tabs that don't fit, and tabs keep most of their name

Tabs shrank to 110 points before the strip scrolled, so with many open they
became a few characters each and nothing showed that more were hidden. Tabs
now stop at 180 points (most of a name), and once even that doesn't fit,
arrows appear either side of them: each steps about one tab, and an arrow is
dimmed at its end, so it is clear whether more tabs lie that way. The mouse
wheel still scrolls them, and the active tab is still brought into view.

New labels "Scroll tabs left" / "Scroll tabs right" are translated in every
catalog (machine-written; native review welcome).

Refs #397.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: William Max & Co. <info@williammax.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 18:56:45 -04:00
96854d8d92 M5: name line endings in the comment properties combo (#582)
* M5: name line endings in the comment properties combo

The menu showed PDF tokens such as ROpenArrow. It now uses translated labels, and the stored /LE name is unchanged.

* Fix main CI: move the rotated-field render test out of pdfcraft-forms (#591)

#516 added pdfcraft-render as a dev-dependency of pdfcraft-forms for one
rendering test. Both crates are layer 3, so `cargo xtask layers` (run in
CI) rejects the sideways edge and main went red. The test now lives in
crates/engine/tests/form_rotation.rs, where both crates are already
dependencies, and forms drops the dev-dependency. The parity evidence
points at the new location.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* ja: 平ら for the Butt line ending

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:45:54 -04:00
22cbf65628 Tell the user when PdfCraft draws without a GPU (#519) (#627)
* Refs #519: tell the user when PdfCraft draws without a GPU

When wgpu's adapter is a software one (WARP on Windows, llvmpipe on
Linux), show a notice at launch that PdfCraft may be slow. egui-wgpu
only logged it, so the #519 reporter had to find it in pdfcraft.log.

The OpenGL fallback isn't covered: glow reports its renderer only
through unsafe calls. The string is translated in the ten catalogs that
carry the portable-mode notice, with their ATTRIBUTION.toml hashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* zh-hant: the drawing-without-a-GPU notice

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 18:45:27 -04:00
cc31d11227 i18n: complete the Japanese catalog (105 missing strings) and check it like fr/de/ru/zh-hans (#587)
* i18n: complete the Japanese catalog and check it like the other languages

Japanese lacked 105 strings that other languages already have: the
open-link and open-email confirmations, DPI settings, the script sandbox
note, XFA form notes, signing, table and page-mode settings, and the
Contributors / Models statistics. Each now has a Japanese row, so the
interface no longer falls back to English for them.

`japanese_covers_ui_literals` joins the French, German, Russian and
Simplified Chinese checks: every `tl!("…")` literal in the UI source needs
a Japanese row. ATTRIBUTION.toml carries the catalog's new SHA-256.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* ja: the Fill & Sign flatten setting main gained meanwhile

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 18:45:11 -04:00
AlexisandBrandon Thomas 80e6bc9d98 M14: complete the French (fr) catalog (#579)
Add the 47 labels and messages that had no French translation (redaction
marks, undo/redo labels, file-dialog and script warnings, SHA digests,
contextual Start/Subject/Edit/Type, ...). Written from the plain meaning of
the English text, clean-room, following the existing fr.tsv vocabulary
(vouvoiement, "biffer" for redact).

Update the fr.tsv SHA-256 in ATTRIBUTION.toml and add a credit entry for
the contributor in contributors/people.toml.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 18:44:56 -04:00
56f85f76df M9: more signature validation algorithms (SHA-3, RIPEMD-160, Ed25519, P-521, brainpool, PSS, rsa_sha1) (#386)
* M9: more signature validation algorithms

Validation now reads what real signers write, and says when it is weak:

- Digests SHA-224, SHA-512/224, SHA-512/256, SHA3-224/256/384/512 and
  RIPEMD-160, beside SHA-1 and SHA-2.
- RSA PKCS#1 v1.5 for every digest, in `rsa_pad`: the one expected encoding
  is built and compared byte for byte, with or without the DigestInfo's NULL
  (RFC 8017 App. B.1). A signature over the bare digest, which doesn't say
  which hash it uses, is refused. The signature value must be below the
  modulus (RFC 8017 §5.2.2); s and s + n both verified before.
- RSA-PSS with the hash, MGF1 hash and salt length the signature declares.
- ECDSA as DER, non-minimal DER or raw r||s on P-256, P-384, P-521 and
  brainpoolP256r1/P384r1/P512r1 (BSI "plain" ECDSA OIDs too); brainpoolP512r1
  is hand-written on crypto-bigint because no RustCrypto crate has it.
- Ed25519 (RFC 8419), and the legacy adbe.x509.rsa_sha1 format.
- An algorithm PdfCraft can't check is Unknown, never Invalid.
- SHA-1 and RIPEMD-160 signatures validate, but the signature's details say
  the algorithm is weak (wording pinned by tests).
- Encoding irregularities that were tolerated are listed in the details
  (`verify_noting`; `verify` keeps its signature).

Found while testing, each with a regression test that failed before the fix:
a brainpoolP512r1 public key with a coordinate >= p panicked inside
crypto-bigint's `sub_mod` (the certificate comes from the PDF); it is refused
now, and the point at infinity is an invalid signature, not an error.

Vectors were made with OpenSSL (tests/data/README.md has the commands).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Update comment to clarify MAC digest restrictions

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* M9: a legacy x509.rsa_sha1 signature whose key the verifier rejects is Invalid; only unsupported keys are "can't check yet"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 18:44:38 -04:00
Brandon ThomasandClaude Opus 5.5 d90b3f897f Refs #103: ship the OCR models in the desktop release packages (#630)
Release builds said "Text recognition isn't installed: its models are
missing" because no packaging script or release job fetched the OCR
models: only `cargo xtask models` does, into the source tree.

- packaging/env.sh gains stage_models DEST, which runs
  `cargo xtask models DEST` (every ATTRIBUTION.toml kind = "model" file,
  SHA-256 verified, with its licence text). No model file is named, so
  the models PRs #433/#572 add ship without packaging changes.
- `cargo xtask models` also writes ATTRIBUTION.txt crediting each model
  (CC-BY-SA-4.0 requires attribution when redistributed).
- macOS: PdfCraft.app/Contents/Resources/models, before signing.
- Windows: models\ beside pdfcraft.exe in the MSI (WiX v5 <Files>
  harvest) and the portable zip; test-msi.ps1 checks the folder.
- Linux deb/rpm/tar.gz/AppImage, Flatpak bundle and FreeBSD tarball:
  share/pdfcraft/models beside bin/. Models::search_dirs now also looks
  in <exe dir>/../share/pdfcraft/models (Models::dirs_beside_exe); a
  test builds every package layout on disk and finds the models in each.
  release.yml and freebsd.yml check the packages contain them.
- The missing-models messages no longer tell end users to run cargo,
  and the 11 catalogs that keyed an old PRINTCRAFT_MODELS wording (or
  the previous one) now key the current English text; ATTRIBUTION
  hashes updated. A test checks the translations match.

The web (wasm) build, which the issue reports, still has no models; this
is the desktop half only.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:42:23 -04:00
Márk BartalosandBrandon Thomas b0548e64a7 M14: correct Hungarian Windows Store and saved-signature labels (#595)
* M14: correct Hungarian Windows Store and saved-signature labels

Follow-up to the Magyar catalog: the certificate list keeps the name Windows Store, saved signature and initials labels take the substituted noun, and the squiggly comment type names the underline.

* hu: keep "Windows-tároló" — "Windows store" is the certificate store, not Microsoft's app store

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 18:39:31 -04:00
445673fe1b M14: clarify Ukrainian wording and restore CI (#584)
* M14: clarify Ukrainian certificate and accessibility wording

Distinguish the certificate holder from the document topic, translate
fixed certificate types, and clarify fingerprints, keyboard access,
table sections and equal field sizes. Validate the complete catalog
with VESUM 6.8.6 and review technical-word exceptions in context.

Fixes #503
Follow-up to #509

Co-authored-by: Codex <noreply@openai.com>

* M14: restore CI after merged test changes

Keep one byte-identical copy of each appearance regression test instead
of five definitions, preserving all three test bodies and assertions.
Correct MCP resource parity evidence to the existing passing test name.

Co-authored-by: Codex <noreply@openai.com>

* ROADMAP: drop the CI follow-up sentence (main fixed the duplicated tests and MCP evidence in #605/#608)

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 18:38:18 -04:00
75b31fb04d M10: Print ▸ Properties… opens the printer driver's own settings (#482)
* M5.7: date format and date language for Fill & Sign in the engine

* M5: flatten Fill & Sign marks when saving

Saving can now bake typed text, marks and signatures into the page, from Preferences or doc_save. The option defaults off, so a normal save still leaves them editable, and other comments and form fields stay.

* M4: zoom the Organize page grid

The page grid draws its pages at 50% to 300%: toolbar buttons with a
percentage that resets to 100%, trackpad pinch or Ctrl/⌘ + wheel about
the page under the pointer, and ⌘+ / ⌘− / ⌘0 (which no longer change the
hidden page view's zoom while the grid shows).

Thumbnails are re-rendered sharper in a few steps as the grid grows,
capped so one document's thumbnails stay within 256 MB. The page under
the pointer (or the first one in view) stays where it is across a zoom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: zoomed grid pages are rendered at the size drawn

A zoomed page grid stretched 132 pt thumbnails, and the stepped detail
that was meant to help was capped by a memory budget, so a long document
stayed blurry at any zoom.

Pages drawn larger than their thumbnails now get a render at exactly the
size drawn while they are in view; the thumbnail stands in until it
arrives. Only pages in view keep one, so memory no longer depends on the
page count, and thumbnails go back to their one fixed size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M5.7: fill_sign_date_format tool; format and language on fill_sign_add

* M5.7: Preferences ▸ Date format for Fill & Sign dates (refs #158)

* M5.7: refuse Fill & Sign dates the PDF text font can't hold

* M5.7: Spanish labels for Preferences ▸ Date format

* M10: Print ▸ Properties… opens the printer driver's own settings

CUPS: the queue's PPD job options (with lpoptions defaults), grouped in the
Print dialog and sent with this job as lp -o key=value; installable options
and what the dialog sets itself are left out. Windows: the driver's Printing
Preferences window through printui (no unsafe FFI), saved as the printer's
per-user defaults. Headless: printer_options tool and doc_print options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* View: the middle button pans instead of drawing with markup tools

egui's drag responses accept every pointer button, so on Windows and macOS a
middle-button drag reached the page tools: with Draw (ink) selected it drew a
stroke, and other tools cropped, selected or moved. Linux was unaffected
because its middle-click auto-scroll already blocks page input.

Outside Linux a middle-button drag that starts in the viewport (document or
Organize Pages) now pans 1:1 while the button is held, with a Grabbing
cursor, and page input is blocked from the press through the release. Linux
keeps its latched auto-scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: a short page gone to from the Pages panel stays current

The current page was whichever page showed most of itself. Going to a short
page (a cheque) above a tall one scrolls it to the top, where the tall page
below shows more, so the current page and the Pages panel's highlight moved
to the tall page instead of the one clicked. The page navigated to now stays
current while it is wholly on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: page operations from the Pages panel: context menu and drag to reorder

Right-clicking a thumbnail offers Extract pages, Cut, Copy and Paste after, on
the selection or on the page clicked (which becomes the selection), as the
organize grid does. Dragging thumbnails moves the selection, or the page
grabbed, to the gap it is dropped on: a bar marks the gap, the page count
follows the pointer, and the panel scrolls near its edges. The move is one
undoable step; the moved pages stay selected and the current page follows
its page. Documents that forbid assembly keep click-only thumbnails.

Parity: view.thumbnail-context-ops is shipped, with what it still lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pages panel: only the primary button drags thumbnails; a drag left over after the panel closed or the document changed is dropped

* Fill & Sign dates: German, Bulgarian, Hungarian and Ukrainian month and weekday names, matching the interface languages

* uk: keep the author's wording for the flatten setting

---------

Co-authored-by: KissT <github@kisst.net>
Co-authored-by: Mark Bartalos <bartalos86@gmail.com>
Co-authored-by: samehelhawary <basio20044@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cody Vogt <Cody.Vogt@caltechsurveys.com>
Co-authored-by: William Max & Co. <info@williammax.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 14:27:02 -04:00
d592dbb9c4 Fill & Sign: date format and date language in Preferences (refs #158) (#505)
* M5.7: date format and date language for Fill & Sign in the engine

* M5: flatten Fill & Sign marks when saving

Saving can now bake typed text, marks and signatures into the page, from Preferences or doc_save. The option defaults off, so a normal save still leaves them editable, and other comments and form fields stay.

* M4: zoom the Organize page grid

The page grid draws its pages at 50% to 300%: toolbar buttons with a
percentage that resets to 100%, trackpad pinch or Ctrl/⌘ + wheel about
the page under the pointer, and ⌘+ / ⌘− / ⌘0 (which no longer change the
hidden page view's zoom while the grid shows).

Thumbnails are re-rendered sharper in a few steps as the grid grows,
capped so one document's thumbnails stay within 256 MB. The page under
the pointer (or the first one in view) stays where it is across a zoom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: zoomed grid pages are rendered at the size drawn

A zoomed page grid stretched 132 pt thumbnails, and the stepped detail
that was meant to help was capped by a memory budget, so a long document
stayed blurry at any zoom.

Pages drawn larger than their thumbnails now get a render at exactly the
size drawn while they are in view; the thumbnail stands in until it
arrives. Only pages in view keep one, so memory no longer depends on the
page count, and thumbnails go back to their one fixed size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M5.7: fill_sign_date_format tool; format and language on fill_sign_add

* M5.7: Preferences ▸ Date format for Fill & Sign dates (refs #158)

* M5.7: refuse Fill & Sign dates the PDF text font can't hold

* M5.7: Spanish labels for Preferences ▸ Date format

* View: the middle button pans instead of drawing with markup tools

egui's drag responses accept every pointer button, so on Windows and macOS a
middle-button drag reached the page tools: with Draw (ink) selected it drew a
stroke, and other tools cropped, selected or moved. Linux was unaffected
because its middle-click auto-scroll already blocks page input.

Outside Linux a middle-button drag that starts in the viewport (document or
Organize Pages) now pans 1:1 while the button is held, with a Grabbing
cursor, and page input is blocked from the press through the release. Linux
keeps its latched auto-scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: a short page gone to from the Pages panel stays current

The current page was whichever page showed most of itself. Going to a short
page (a cheque) above a tall one scrolls it to the top, where the tall page
below shows more, so the current page and the Pages panel's highlight moved
to the tall page instead of the one clicked. The page navigated to now stays
current while it is wholly on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: page operations from the Pages panel: context menu and drag to reorder

Right-clicking a thumbnail offers Extract pages, Cut, Copy and Paste after, on
the selection or on the page clicked (which becomes the selection), as the
organize grid does. Dragging thumbnails moves the selection, or the page
grabbed, to the gap it is dropped on: a bar marks the gap, the page count
follows the pointer, and the panel scrolls near its edges. The move is one
undoable step; the moved pages stay selected and the current page follows
its page. Documents that forbid assembly keep click-only thumbnails.

Parity: view.thumbnail-context-ops is shipped, with what it still lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pages panel: only the primary button drags thumbnails; a drag left over after the panel closed or the document changed is dropped

* Fill & Sign dates: German, Bulgarian, Hungarian and Ukrainian month and weekday names, matching the interface languages

---------

Co-authored-by: Mark Bartalos <bartalos86@gmail.com>
Co-authored-by: samehelhawary <basio20044@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cody Vogt <Cody.Vogt@caltechsurveys.com>
Co-authored-by: William Max & Co. <info@williammax.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 14:26:29 -04:00
8e3782d5ca Flatten Fill & Sign marks when saving (#464)
* M5: flatten Fill & Sign marks when saving

Saving can now bake typed text, marks and signatures into the page, from Preferences or doc_save. The option defaults off, so a normal save still leaves them editable, and other comments and form fields stay.

* M4: zoom the Organize page grid

The page grid draws its pages at 50% to 300%: toolbar buttons with a
percentage that resets to 100%, trackpad pinch or Ctrl/⌘ + wheel about
the page under the pointer, and ⌘+ / ⌘− / ⌘0 (which no longer change the
hidden page view's zoom while the grid shows).

Thumbnails are re-rendered sharper in a few steps as the grid grows,
capped so one document's thumbnails stay within 256 MB. The page under
the pointer (or the first one in view) stays where it is across a zoom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: zoomed grid pages are rendered at the size drawn

A zoomed page grid stretched 132 pt thumbnails, and the stepped detail
that was meant to help was capped by a memory budget, so a long document
stayed blurry at any zoom.

Pages drawn larger than their thumbnails now get a render at exactly the
size drawn while they are in view; the thumbnail stands in until it
arrives. Only pages in view keep one, so memory no longer depends on the
page count, and thumbnails go back to their one fixed size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* View: the middle button pans instead of drawing with markup tools

egui's drag responses accept every pointer button, so on Windows and macOS a
middle-button drag reached the page tools: with Draw (ink) selected it drew a
stroke, and other tools cropped, selected or moved. Linux was unaffected
because its middle-click auto-scroll already blocks page input.

Outside Linux a middle-button drag that starts in the viewport (document or
Organize Pages) now pans 1:1 while the button is held, with a Grabbing
cursor, and page input is blocked from the press through the release. Linux
keeps its latched auto-scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: a short page gone to from the Pages panel stays current

The current page was whichever page showed most of itself. Going to a short
page (a cheque) above a tall one scrolls it to the top, where the tall page
below shows more, so the current page and the Pages panel's highlight moved
to the tall page instead of the one clicked. The page navigated to now stays
current while it is wholly on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: page operations from the Pages panel: context menu and drag to reorder

Right-clicking a thumbnail offers Extract pages, Cut, Copy and Paste after, on
the selection or on the page clicked (which becomes the selection), as the
organize grid does. Dragging thumbnails moves the selection, or the page
grabbed, to the gap it is dropped on: a bar marks the gap, the page count
follows the pointer, and the panel scrolls near its edges. The move is one
undoable step; the moved pages stay selected and the current page follows
its page. Documents that forbid assembly keep click-only thumbnails.

Parity: view.thumbnail-context-ops is shipped, with what it still lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pages panel: only the primary button drags thumbnails; a drag left over after the panel closed or the document changed is dropped

* M5: translate Fill & Sign flatten labels into Ukrainian

The coverage test failed because the new preference strings had no Ukrainian entries.

* Fix main CI: move the rotated-field render test out of pdfcraft-forms (#591)

#516 added pdfcraft-render as a dev-dependency of pdfcraft-forms for one
rendering test. Both crates are layer 3, so `cargo xtask layers` (run in
CI) rejects the sideways edge and main went red. The test now lives in
crates/engine/tests/form_rotation.rs, where both crates are already
dependencies, and forms drops the dev-dependency. The parity evidence
points at the new location.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* uk: the author's wording for the flatten setting

---------

Co-authored-by: samehelhawary <basio20044@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cody Vogt <Cody.Vogt@caltechsurveys.com>
Co-authored-by: William Max & Co. <info@williammax.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 14:25:08 -04:00
f28cbbcdd9 M4: zoom the Organize page grid (#468)
* M4: zoom the Organize page grid

The page grid draws its pages at 50% to 300%: toolbar buttons with a
percentage that resets to 100%, trackpad pinch or Ctrl/⌘ + wheel about
the page under the pointer, and ⌘+ / ⌘− / ⌘0 (which no longer change the
hidden page view's zoom while the grid shows).

Thumbnails are re-rendered sharper in a few steps as the grid grows,
capped so one document's thumbnails stay within 256 MB. The page under
the pointer (or the first one in view) stays where it is across a zoom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: zoomed grid pages are rendered at the size drawn

A zoomed page grid stretched 132 pt thumbnails, and the stepped detail
that was meant to help was capped by a memory budget, so a long document
stayed blurry at any zoom.

Pages drawn larger than their thumbnails now get a render at exactly the
size drawn while they are in view; the thumbnail stands in until it
arrives. Only pages in view keep one, so memory no longer depends on the
page count, and thumbnails go back to their one fixed size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* View: the middle button pans instead of drawing with markup tools

egui's drag responses accept every pointer button, so on Windows and macOS a
middle-button drag reached the page tools: with Draw (ink) selected it drew a
stroke, and other tools cropped, selected or moved. Linux was unaffected
because its middle-click auto-scroll already blocks page input.

Outside Linux a middle-button drag that starts in the viewport (document or
Organize Pages) now pans 1:1 while the button is held, with a Grabbing
cursor, and page input is blocked from the press through the release. Linux
keeps its latched auto-scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: a short page gone to from the Pages panel stays current

The current page was whichever page showed most of itself. Going to a short
page (a cheque) above a tall one scrolls it to the top, where the tall page
below shows more, so the current page and the Pages panel's highlight moved
to the tall page instead of the one clicked. The page navigated to now stays
current while it is wholly on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: page operations from the Pages panel: context menu and drag to reorder

Right-clicking a thumbnail offers Extract pages, Cut, Copy and Paste after, on
the selection or on the page clicked (which becomes the selection), as the
organize grid does. Dragging thumbnails moves the selection, or the page
grabbed, to the gap it is dropped on: a bar marks the gap, the page count
follows the pointer, and the panel scrolls near its edges. The move is one
undoable step; the moved pages stay selected and the current page follows
its page. Documents that forbid assembly keep click-only thumbnails.

Parity: view.thumbnail-context-ops is shipped, with what it still lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pages panel: only the primary button drags thumbnails; a drag left over after the panel closed or the document changed is dropped

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cody Vogt <Cody.Vogt@caltechsurveys.com>
Co-authored-by: William Max & Co. <info@williammax.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 14:24:45 -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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
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 <echelon@gmail.com>
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 <echelon@gmail.com>
2026-10-09 13:13:12 -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 <codex@openai.com>

* 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 <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
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 <codex@openai.com>

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

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 12:39:57 -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 <echelon@gmail.com>
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 <noreply@anthropic.com>

* de: drop a leftover merge marker

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 12:23:15 -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
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) <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 09:26:15 -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
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 <noreply@anthropic.com>
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
Xus BadiaandClaude Opus 5.5 084f7636ea macOS DMG: branded window background and icon layout
The DMG opened as a plain Finder window. It now shows the app icon's figure on
the PdfCraft colour, with PdfCraft.app and the Applications link side by side.
Same approach as PhotoCraft's and VectorCraft's.

- packaging/macos/dmg/: background.svg (source; text outlined, no paper grain,
  the figure an <image> of assets/app-icon), background.tiff (1x + 2x HiDPI,
  16-colour palette, Deflate, sRGB; ~95 KB) and dmg-layout.DS_Store (Finder
  view settings: window, icon size 128, positions, background).
- generate.py writes the TIFF and the layout on any OS, byte-for-byte
  reproducible, with nothing from the machine that ran it.
- package.sh copies them into the staging folder; the image is still built with
  hdiutil makehybrid. Nothing extra runs in the signing job.
- The volume name drops the version: the layout finds the background through
  an alias that includes the volume name. The DMG file name keeps the version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:38:19 +02:00
0afe0b7094 Keyboard shortcut to enter page number to jump to (#393)
* added view.read_mode command

* added view.focus_page_input

* Go to page…: Ctrl+Shift+N / ⇧⌘N (Acrobat's), no indexing, rustfmt, catalog entries

Shift+Y alone (the first proposal) would have typed a capital Y in text fields
on Windows and Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:37:22 -04:00
31f3631868 Home: pin folders and list their newest PDFs (#383)
* Home: pin folders and list their newest PDFs

File > Pin folder to Home... (or "Pin a folder..." on Home) pins up to 8
folders. Each lists its newest PDFs with how long ago and size, opens one
with a click, and can be unpinned. Folders are read on a worker thread and
listed again every few seconds while Home shows, so new scans appear by
themselves; an unreadable folder (moved, offline synced drive) stays pinned
and says so. A folder is described by where it is ("iCloud Drive",
"OneDrive - Contoso", "Documents > Work") instead of its full path.

Cloud drives work through their local synced folder; nothing goes through a
provider API. Settings keep absolute paths only, deduplicated and capped.

Tests: crates/ui-egui/tests/folders.rs (8) and folders_ui unit tests (4).
Command label translated in every catalog; Home strings in fr, es, ja,
zh-hans and zh-hant; catalog hashes updated in ATTRIBUTION.toml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add my contributor name (amitabhthakur)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Home pinned folders: Russian strings; regenerate ATTRIBUTION.md

The Russian catalog (#375) landed while this branch was open: its coverage
tests need the new command label and Home strings. ATTRIBUTION.md is
regenerated (cargo xtask assets --write), which also adds the ru.tsv row
that #375 left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 19:59:24 -04:00
4995da0653 i18n: add Russian (ru) interface translation (#375)
* i18n: add Russian (ru) interface translation

- Add ru.tsv catalog (~1950 entries, clean-room translations following PhotoCraft terminology: Слой, Образцы, Обработка контуров, Монтажная область, Обводка, Заливка)
- Register ru in LANGUAGES with plural_russian (one/few/many with 11-14 exception)
- Add Russian tests: registration, command/catalogue coverage, UI literals, history/diagnostics, plural forms
- Add ATTRIBUTION.toml entry for ru.tsv
- Update docs/localization.md, ROADMAP.md with Russian language
- All i18n tests pass, cargo fmt and clippy clean

* ru: translate labels added on main since the catalog was written; Russian in the Latin-font coverage test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ru: '>' in the JavaScript-off hint, as the other catalogs (Inter has no ▸)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:24:51 -04:00
2a35a1a76f M11: Optimize PDF and Reduce File Size run in the background with a progress bar (#370)
* M11: Optimize PDF and Reduce File Size run in the background with a progress bar

Advanced optimization and Reduce File Size did all their work on the UI
thread, so on a large file the window stopped repainting until they were
done and looked as if it had crashed.

- pdfcraft-optimize: optimize_with_progress reports each image and the
  clean-up; returning false stops with OptimizeError::Cancelled.
- engine: Session::optimize_job captures a copy of the document and the
  choices; OptimizeJob::run reports each OptimizeStage (removing user data,
  images, cleaning up, merging, writing), stops on request with
  EditError::Cancelled and turns a panic into an error so a worker always
  reports back. optimized_bytes is the job run in place.
- ui: both commands run the job on a worker thread (inline in tests and on
  the web), like OCR and Export. A card at the bottom centre shows the
  stage ("Optimizing... image 14 of 40"), a bar that eases towards the
  fraction with a light sweeping across it, the percentage and Cancel;
  notices stack above it. A second run is refused while one is going. The
  control channel's state includes the progress.

The headless doc_optimize and doc_reduce tools stay synchronous. New
strings are translated for es, fr, ja, zh-hans, zh-hant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Clippy: as_chunks in the signature image background test (follow-up to #355)

#360 switched the image test to as_chunks; #355 added another chunks_exact(4)
in the background test, which clippy's chunks_exact_to_as_chunks rejects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Optimize: report a failed worker spawn instead of waiting forever

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 18:46:34 -04:00