Commit Graph
125 Commits
Author SHA1 Message Date
Ufuk EfeandBrandon Thomas 3d2bdfe71a feat(i18n): add complete Turkish interface language support (#1057)
* feat(i18n): add complete Turkish interface language support

* Turkish: rows for the labels that landed alongside this catalog; refresh hashes

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 16:34:25 -04:00
d1980ec99f M14: Kazakh interface language (#955)
* M14: Kazakh interface language

A complete clean-room Kazakh (kk) translation of the interface: 2105
catalogue rows covering every English key (commands, tool catalogue,
UI literals), with the plural forms and the DATE_LANGUAGES entry
(months, months_short, weekdays) the other languages have.

- Register 'kk' (Қазақша) in LANGUAGES; every kk-* locale resolves to
  it, and it appears in Preferences and the date-language picker.
- Three coverage tests cloned from the Ukrainian battery: every
  registered command and All tools label, every tl! literal in the UI
  sources, and no untranslated English left (format-only strings excepted).
- tests/fonts.rs covers the catalogue's Kazakh Cyrillic (әғқңөұүһі)
  against Inter and JetBrains Mono, which both carry the letters.
- ATTRIBUTION.toml gains the kk.tsv entry (contributor-original,
  MIT OR Apache-2.0).

cargo test -p pdfcraft-ui-egui (lib 171 + fonts 7), clippy -D warnings,
cargo fmt --check and cargo xtask assets all pass.

* Tests: give the Kazakh literal check its own doc comment and restore the Ukrainian one

* Kazakh: rows for the labels that landed alongside this catalog; refresh hashes

---------

Co-authored-by: pechnikov2026-star <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 16:22:16 -04:00
58ae136267 Add Greek (el) interface localization (#994)
* feat(i18n): add Greek localization

* Greek: same place in both language lists, covered by the font test; Μαΐ

* Greek: rows for the labels that landed alongside this catalog; refresh hashes

---------

Co-authored-by: George Athenakis <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 16:16:40 -04:00
a0d68dafd2 M14: Slovenian (Slovenščina) interface language (#1091)
* Add Slovenian translation

* Slovene: a date language for the date picker; drop two stale PRINTCRAFT_MODELS rows

* Slovene: rows for the labels that landed alongside this catalog; refresh hashes

---------

Co-authored-by: spacekomet <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 16:09:24 -04:00
cc9392922b M14: Italian catalog review: meaning, agreement, consistency (47 entries) (#1025)
* M14: Italian catalog review: meaning, agreement, consistency (47 entries)

A native-speaker review of it.tsv, written from the English meaning (no
other product's strings consulted).

- Meaning: "Margins (in)" is inches (Margini (pollici)); Clear Recent Files
  empties the list rather than deleting files; the comb field ("Suddividi
  in", "Caselle per carattere"); rich text formatting is not RTF;
  checkboxes and style values read as options, not commands; the
  position fields measure from the bottom-left corner; "checked"
  properties are the ticked ones.
- Agreement: counts that read wrong with 1 ("1 selezionati", "Sbloccati 1
  file") become "Label: {n}"; sentences with {kind}/{tool}, which can be
  feminine, no longer use masculine pronouns; plural "revisioni", "per 5
  anni", subject agreement in two messages.
- Consistency: “ ” quotes and straight apostrophes throughout; "carattere"
  rather than "font"; "combinare" for Combine; "email"; "Contributori";
  "Adatta all'area visibile"; progress messages in the impersonal form;
  quoted option names match their labels; "Più pagine per foglio" for the
  print layout.

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

* Italian: one row for Unlocked {n} of {m} files (the review's wording)

---------

Co-authored-by: magdale76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:59:47 -04:00
de8e0176ed M14: complete the Czech (cs) interface catalog (#1066)
* M14: complete the Czech (cs) interface catalog

Czech covered the menus and a few later additions (167 keys). The catalog
now has every key the code uses (2,194): Home, All tools, panels, dialogs,
notices, diagnostics and history labels. Translated clean-room from the
meaning of the English labels; the 167 existing entries are unchanged.

- The @plural messages use the three Czech forms (one | few | other);
  other counted messages use count-neutral wording ("Vybráno stránek: {n}").
- "calendar date" months are genitive ("9. října 2026"); "Undo {action}"
  and "Redo {action}" are whole phrases ("Zpět: {action}").
- Labels the code joins with a leading or trailing space keep it.
- The key-name entries (context "key") are kept as they were.

Tests that relied on the incomplete catalog:
- czech_covers_every_menu_label keeps a short list of strings spelled the
  same in Czech (product and format names, Text, Index, Metadata, Model,
  templates such as "{0}: {1}"), compares leading and trailing spaces with
  the source instead of forbidding them, and requires a catalog without
  parse errors.
- date_format.rs expected the English fallback "Same as interface
  (Čeština)" under the Czech interface; that label is now translated.

ATTRIBUTION.toml: new SHA-256 of cs.tsv.

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

* M14: check the Czech catalog like the other complete catalogs

Czech now has the same coverage gates as the complete catalogs:
- czech_covers_commands_and_catalogue: every registered command, menu
  title and All tools group, section and item;
- czech_covers_ui_literals: every tl!("…") literal in the UI source;
- czech_plurals_dates_and_history_labels: 1/3/5 pages and 0 fields, the
  genitive date ("9. října 2026"), and Undo/Redo/Change history labels
  that keep captured names.
cs also joins the lists in missing_ocr_models_messages_are_translated and
bulk_field_properties_labels_are_in_every_full_catalog.

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

* i18n (cs): terminology review fixes

An independent review of the Czech catalog found 5 entries with a wrong
or misleading meaning and 44 that read unnaturally or inconsistently.

Meaning:
- US formats say so: "Čísla sociálního zabezpečení (USA)" (not the Czech
  birth number) and "Telefonní číslo (USA)" for the field format.
- Mouse Exit/Enter triggers: "Odjetí kurzoru" / "Najetí kurzoru".
- Rich text in form fields is not RTF; Form XObjects are not forms.

Wording and consistency:
- Home: pinning folders refers to "úvodní obrazovka" (also in the existing
  "Pin folder to Home…"); "úvodní stránka" reads as the first page.
- Callout is "Popisek se šipkou"; Self-signed is "Samopodepsaný" (fixes
  the agreement in "Samopodepsaná ID").
- Reset (forms), Apply redactions, guided actions, the radio button and
  checkbox styles, line endings (also the existing arrow entries), border
  styles, "Omezit na … znaků", "Rozlišovat velikost písmen", grammar in
  several messages, and the About window wording shared with the other
  Crafting Apps.

History labels for the Creator and Producer document information keys
use the Document Properties wording: "Aplikace" and "Producent PDF".

ATTRIBUTION.toml: new SHA-256 of cs.tsv.

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

* contributors: add my own people.toml entry

Own entry only, as AGENTS.md describes: credited by the name on my public
GitHub profile (sync_github_name), otherwise by @username.

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

* i18n (cs): translate "Offset from centre" (watermark offset, #1076)

New upstream string added after the catalog was last synced.

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

* i18n (cs): follow the reworded Combine files strings (#1032)

Upstream generalised the Combine files dialog (images and text files, a
prompt for files that can't be read) and replaced six strings. The old
Czech entries are dropped and the ten new ones translated.

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

* i18n (cs): translate the new Enhance page / camera image strings (#1027)

Four strings added upstream after the catalog was last synced.

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

* i18n (cs): margin labels Top/Bottom read Nahoře/Dole

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

* i18n: translate the Digital ID errors, the Signatures panel and key usages

The digital-ID dialog printed its validation errors, the Signatures panel
headings ("Rev. n: Signed by ...", "Unsigned signature field"), the
certificate issuer line of the signed-document bar and the nine key-usage
names in English. Translate them at the point where they are drawn, the way
title() and notify_fmt() already do, so engine and OS error texts that
share those paths pass through unchanged. Czech catalog entries added; other
languages fall back to English.

The signature appearance preview is left alone on purpose: it mirrors the
Helvetica text the engine bakes into the PDF ("Digitally signed by", "Reason:"),
which is document content, not interface.

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

* i18n: translate form action, print, panel layout, rendering and OCR language texts

Route the remaining plain-English messages through the translation lookup
where they are drawn: the Add-action validation hints, the Cut and stack
print error (preview and notice), the two panel-layout notices, the
"Rendering..." placeholders on unrendered pages, the page number under the
Page Boxes preview (reuses "Page {label}") and the OCR language name.

The messages of the docking validator and the engine and OS errors stay
English: they report internal invariants or text this code does not own.
"My stamps" stays too: it is stored as the category of the user's stamps, so
translating it would split a library when the language changes. The dynamic
stamp chip text mirrors the English text baked into the stamp and is left as is.

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

* i18n (cs): translate the Appearance preferences and the wheel scroll speed

The strings added upstream with the Appearance section and the mouse wheel
scroll speed setting were missing from the Czech catalog, so
czech_covers_ui_literals failed after the merge.

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

* i18n: use plural forms for counts built in code

The saved notice after Optimize ("3 images optimized", "2 items discarded"),
the pixel sizes in Export all images, the exact size in bytes (human_size and
the document properties) and the history label of a multi-field delete were
assembled from English words and a trailing "s". Use trn() for them, with
@plural entries in the Czech catalog (1 | 2-4 | 5+), and translate the
"Delete N fields" history label in action_label(). English output is unchanged;
other languages without a plural entry keep the English wording.

The Czech plural test now also checks bytes, optimized images and the
Delete N fields label.

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

* i18n: translate the text-box and organize toolbar tooltips

"Done adding text" and "Discard this text (Esc)" (the buttons under a new
text box) and the Rotate tooltips of the organize toolbar were passed to
icons::button as plain English. Translate them at the call site, as the
other icon buttons do.

The two text-box tooltips are tl!() literals, so every complete catalog
(ar, bg, cs, de, fr, hu, it, ja, pt-br, ru, uk, zh-hans) has to carry them;
the entries for the languages other than Czech are AI drafts that want a
native speaker's look. The Rotate tooltips already had translations.

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

---------

Co-authored-by: Losminek <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:55:21 -04:00
513a1ecc3a i18n: let the layers below hand their sentences over, so they can be translated (#478)
* i18n: let the layers below hand their sentences over, so they can be translated

The accessibility findings, the PDF/A problems, the signature details, the
page-range mistakes and the edit labels were English in all ten languages,
because matching their wording from the UI with a tl! literal would break
silently whenever the engine changed it.

So each layer now hands the wording over instead, apart from the document's
own words: a WORDINGS list of every sentence it can say, with holes in it, and
said(&words), which looks the wording up through the closure it is given and
fills the holes in a single pass. in_english changes nothing, for the CLI and
the tests; the UI passes &i18n::words, a catalog lookup. A wording with no
entry comes back as it stands, which is the English, so the nine catalogs this
does not touch read exactly as before.

a11y::report_html takes a language tag and a words closure, so a saved report
reads the way the panel read, down to its lang attribute. print::PrintError
gained a variant per thing a person can be wrong about, with the numbers apart
from the words; faults rather than mistakes keep their own text.

Spanish translates all of it (107 entries). spanish_covers_what_the_engine_says
requires an entry per wording in the eight lists and
spanish_keeps_the_holes_in_what_the_engine_says requires the holes to survive
the translation, so the catalog cannot fall behind the engine unnoticed.

No new tl! literal, so the Chinese, French and Russian UI-literal gates are
unaffected.

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

* Sign tests from #1099: compare against the &'static str kinds

* Camera tests: undo returns a Label now

---------

Co-authored-by: Fernando Areán <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:45:51 -04:00
315a0cc458 Issue #432: global Units preference (mm, cm, in, pt), defaulting to the system region (#463)
* Issue #432: global Units preference (mm, cm, in, pt), defaulting to the system region

Preferences ▸ Documents and view ▸ Units sets the unit for page sizes
(Document Properties, Print), Header & Footer margins, the start of Set
Page Boxes and the Measure tool on pages without a viewport scale. On
first start it follows the system region: AppleMeasurementUnits on macOS,
Region ▸ Measurement system (iMeasure) on Windows, then LC_ALL /
LC_MEASUREMENT / LANG; inches for US, LR and MM, millimetres elsewhere.

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

* Issue #432: macOS falls back to AppleLocale when the measurement system was never set

AppleMeasurementUnits only exists in the global domain once someone changed
Measurement system in System Settings, and apps started from the Finder get no
LANG, so a German Mac with untouched settings landed on inches. The region of
AppleLocale (always present) decides next; AppleMeasurementUnits still wins.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

* Issue #432: Units labels in the Arabic and Italian catalogs new on main

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

* Issue #432: the Print dialog's PDF page size follows the Units preference

Main's new "PDF page: {w} × {h} in" label was always in inches. It now uses
the preferred unit like the sheet size next to it; each catalog keeps its
wording with the size in place of the inch values.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Tristan von Kries <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:33:54 -04:00
RidwanandBrandon Thomas 5df8f3b33a Issue #995: unlock a restricted document with its permissions password (#1127)
A PDF protected with only a permissions (owner) password opens without
asking, restricted, and nothing let the user enter that password
afterwards: the security notice only led to a read-only Security tab and
the hint said to reopen the file with the password, which the app had no
way to do (#995, #1077).

Now the security notice and Document Properties > Security offer Unlock...,
and Change settings / Remove security on a restricted document ask for the
permissions password first and then carry on, as Acrobat does. The open
password is refused there.

- crypt: SecurityHandler::authenticate_owner accepts only the owner
  password and refuses a different file key.
- cos: Document::unlock upgrades the open document in place; owner and user
  passwords unlock the same file key, so nothing is decoded again.
- engine: Session::unlock (not an edit: no undo step, not marked unsaved)
  also unlocks the undo and redo states and reopens saves as owner;
  Document::can_unlock, EditError::WrongPassword, command protect.unlock.
- ui: the permissions password prompt shares the open prompt's modal; the
  notice and Security tab get Unlock...; the disabled-command hints no
  longer point at reopening the file.
- i18n: the new strings in all 15 full catalogs (catalog hashes updated).

Tests cover crypt, cos and engine (all four algorithms, wrong and open
passwords, undo, redo, save, removing security afterwards) and the UI
(notice flow, Remove security asking first).

Closes #995
Closes #1077

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:18:01 -04:00
8f8e0236aa M10: Enhance camera image cleans the photo up and chooses its look (part 4 of #1008, closes #1034) (#1038)
* M10: enhance camera images: straighten a page from four corners (ocr.camera-perspective)

Four corners on a page (any order, a convex quadrilateral) are warped to an
upright rectangle that replaces what the page shows, read at the page
picture's own resolution (at most 600 dpi) and kept at that physical size.
One undoable step through Replace Pages, so comments, links and bookmarks
stay.

- geom: a pure 3x3 homography solve and corner ordering (no dependencies);
  collinear, concave and non-finite input gives None.
- engine: camera::warp (bilinear, over white, capped at 40 MP) and
  Session::enhance_camera_page.
- automation: page_enhance_camera (corners in top-left-origin points).
- parity: ocr.camera-perspective is partial; the UI handles, automatic edge
  detection and contrast cleanup are still missing.

Refs #1008.

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

* M10: Enhance camera image: drag four corners on the page, then Enhance page (ocr.camera-perspective)

Scan & OCR ▸ Enhance camera image (ocr.enhance_camera) shows four corner
handles on the current page, inset 5% from its edges, with what will be cut
away dimmed. Enhance page straightens what they enclose through
Session::enhance_camera_page (one undoable step); Cancel and Esc leave the
page alone. The buttons float at the top right, clear of a centred page's
corners, and the instructions are a notice, as for Crop.

A command of its own, beside Enhance scanned file (ocr.enhance), which
stays for scan correction (#1012).

- ui-egui: camera_ui (handles, dimming, bar), QuickTool::EnhanceCamera,
  quick_tool "enhance-camera" for ui.state and the shot example.
- Catalogs: the three new labels in every language with UI coverage
  (plus es, pl, zh-hant); ATTRIBUTION.toml hashes updated.
- automation: page_enhance_camera is linked to the command.
- parity: ocr.camera-perspective lists the command and the UI test.

Refs #1008.

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

* M10: Enhance camera image finds the document's edges (ocr.camera-perspective)

The corners now start on the photographed document's edges when they can
be found, else inset 5% as before.

camera_edges::detect_document, pure Rust and deterministic: shrink to 400
px, blur, Sobel edges thinned and thresholded against the 98th percentile;
a Hough transform where each edge votes within 6 degrees of its own
normal; pairs of near-horizontal and near-vertical lines make convex
quadrilaterals (at least 12% of the picture), kept when every side lies on
a matching edge along at least 55% of its length and scored by area times
the square of the mean support; each side is then refitted through its
edge points (total least squares). None when nothing convinces.

- Session::detect_camera_corners (user space); page_enhance_camera finds
  the corners itself when `corners` is omitted and reports the corners it
  used and whether they were detected; the UI command starts from them.
- Tests on synthetic photos: plain table, text on the sheet, wood grain
  with a shadow, strong perspective, a 30 degree turn, a sheet running off
  the picture, no document, white on white, bad input; end to end through
  the engine and the tool.
- Parity notes: what is still missing (turning the result upright, rounded
  corners, clean-up).

Refs #1008.

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

* M10: after Enhance page, rotate, crop or organize the page (ocr.camera-perspective)

Enhance camera image no longer closes on Enhance page: the bar then offers
rotate counterclockwise and clockwise for the enhanced page (one undo step
each), Crop pages and Organize pages (which leave the tool for those
tools), and Done; Esc leaves too. A photo of a card often comes out a
quarter turn off until the page is turned upright automatically.

The two icon buttons are named for screen readers. Every label already
exists in the catalogs.

Refs #1008.

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

* M10: Enhance camera image cleans the photo up and chooses its look (#1034)

After Enhance page the bar offers a look (Original, Colour, Greyscale,
Black and white) and a Clean-up slider (0-100, default Colour at 50).
Choosing a look or letting go of the slider enhances the page again from
the straightened photo and turns it as before, so the history keeps one
enhancement (and one turn), never a pile of them.

camera_clean, pure Rust:
- clean: the paper's illumination is estimated per channel on a coarse
  grid (each block's 90th percentile, lifted to the 75th percentile of the
  5x5 blocks around it so a stamp or picture isn't taken for paper, then
  smoothed) and divided out, so casts, shadows and darker edges go and the
  paper turns white; a contrast stretch grows with the level, and from 60
  isolated dark pixels are removed. Colour ink stays.
- apply(look): Original is the photo as taken; Greyscale is the cleaned
  page in grey; Black and white evens the lighting fully, thresholds the
  whole page (the level sets the threshold: higher keeps fainter strokes)
  and despeckles. Black and white is stored as PNG (lossless): JPEG would
  smear grey around every letter.

- Session::enhance_camera_page_at(look, level); page_enhance_camera takes
  `mode` (original, colour, greyscale, bw) and `level` (0-100), refusing
  anything else.
- "Clean-up", "Original", "Greyscale" and "Black and white" in every
  catalog with UI coverage, plus es, pl and zh-hant.
- Tests: shadow and yellow cast become white paper with dark text, a blue
  stamp stays blue, strength grows with the level, specks go but text
  stays, the four looks (black and white is only black and white, the
  threshold moves with the level), bad sizes; a shadowed sheet through the
  engine; level and mode through the tool; the slider and the looks
  re-enhancing from the photo and keeping the turn.
- Parity: ocr.background-removal, ocr.edge-shadow-removal and
  ocr.despeckle are partial (camera images only).

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

* M10: Enhance camera image cleans the photo up and chooses its look (#1034)

After Enhance page the bar offers a look (Original, Colour, Greyscale,
Black and white) and a Clean-up slider (0-100, default Colour at 50).
Choosing a look or letting go of the slider enhances the page again from
the straightened photo and turns it as before, so the history keeps one
enhancement (and one turn), never a pile of them.

camera_clean, pure Rust:
- clean: the paper's illumination is estimated per channel on a coarse
  grid (each block's 90th percentile, lifted to the 75th percentile of the
  5x5 blocks around it so a stamp or picture isn't taken for paper, then
  smoothed) and divided out, so casts, shadows and darker edges go and the
  paper turns white; a contrast stretch grows with the level, and from 60
  isolated dark pixels are removed. Colour ink stays.
- apply(look): Original is the photo as taken; Greyscale is the cleaned
  page in grey; Black and white evens the lighting fully, thresholds the
  whole page (the level sets the threshold: higher keeps fainter strokes)
  and despeckles. Black and white is stored as PNG (lossless): JPEG would
  smear grey around every letter.

- Session::enhance_camera_page_at(look, level); page_enhance_camera takes
  `mode` (original, colour, greyscale, bw) and `level` (0-100), refusing
  anything else.
- "Clean-up", "Original", "Greyscale" and "Black and white" in every
  catalog with UI coverage, plus es, pl and zh-hant.
- Tests: shadow and yellow cast become white paper with dark text, a blue
  stamp stays blue, strength grows with the level, specks go but text
  stays, the four looks (black and white is only black and white, the
  threshold moves with the level), bad sizes; a shadowed sheet through the
  engine; level and mode through the tool; the slider and the looks
  re-enhancing from the photo and keeping the turn.
- Parity: ocr.background-removal, ocr.edge-shadow-removal and
  ocr.despeckle are partial (camera images only).

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

* Camera level: when another edit broke the run, redo what was undone instead of warping the page again

---------

Co-authored-by: magdale76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:57:31 -04:00
durtyrobbinandBrandon Thomas f036d3841b Print: architectural paper presets and PDF scale controls (#599 follow-up) (#1123)
* M10.5: Restore architectural paper presets and PDF scale controls

Port the missing paper catalog and scale controls from PR #599 onto the merged Windows printing implementation. Keep the spooler and background discovery from #847/#1007. Document the feature-by-feature audit and verification limits.

Assisted-by: OpenAI Codex

* Drop the provenance write-up and the screenshot example (the kittest covers the dialog)

* Refresh catalog hashes

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:49:32 -04:00
Nigel Ihza FirdausyandBrandon Thomas a3d30d5866 fix(ui): prevent translated tool labels from overflowing and align German terminology (#939)
* fix(ui): bound translated tool labels and polish German terminology (#828)

* style(ui): keep bounded-label layout readable

* German: keep „Ausfüllen und unterschreiben“ (Fill & Sign isn't a certificate signature) and „Für Barrierefreiheit vorbereiten“

* Refresh catalog hashes

* Test: find the German Fill & Sign heading whole or elided

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:36:41 -04:00
pauliusiv123-rgbandBrandon Thomas 57c9e3a9b4 Issue #431: reload a document when its file changes on disk (#459)
* Engine: reload a document from new bytes, keeping its id

* Issue #431: reload a document when its file changes on disk

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 14:31:32 -04:00
134b98b0a8 M4: open Organize Pages as a selectable thumbnail overview (#1004)
* M4: document the directly accessible page overview

Part of the organize-page-overview change.

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

* M4: open Organize in the page grid with selection and per-page actions

Show the overview from the tool catalogue, Home, palette and control channel. Add checkbox selection and local rotate/delete actions; preserve thumbnail aspect ratios.

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

* M4: translate page selection and local actions

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

* M4: cover overview entry, selection and page actions

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

* M4: record the organizer workflow improvement

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

* M4: link page overview regression evidence

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

---------

Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 13:58:58 -04:00
Joel TonyandBrandon Thomas acda408192 Issue #568: honour the document's copy permission (Table 22, bit 5) (#914)
* Issue #568: honour the document's copy permission

ISO 32000-2 §7.6.4.1 has readers restrict access to an encrypted file
according to its permissions, and Table 22 bit 5 covers "Copy or otherwise
extract text and graphics". PdfCraft reported the bit (Document Properties
shows "Content copying: Not allowed") but ⌘C copied selected text anyway.

- Engine: Document::allows_copying / check_copying, and Needs::Copying for
  Take a snapshot, Export to image/text/Word/HTML/RTF and Export all images.
  Save Image As refuses too.
- UI: ⌘C moves from canvas::shortcuts to the app, which knows the document.
  Refused copies, snapshots and exports say why. The snapshot tool and the
  exports check again when they act, since the tool stays selected across tabs.
- Automation: text_extract (and the MCP text resources), text_paragraphs,
  doc_export_text/images/all_images, doc_export_office and image_save refuse.

Find, read aloud and the accessibility tools keep working: the spec exempts
assistive technology, and searching isn't copying. Page extraction stays with
the assemble bit, as before. Owners and unencrypted files are unaffected.

* Refresh catalog hashes

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 13:51:10 -04:00
Justin WilkinsandBrandon Thomas 56c034b0b3 M9: visible digital signatures can show a drawn, typed or image graphic (#193) (#934)
* M9: visible digital signatures can show a drawn, typed or image graphic (#193)

A visible certificate signature can show a graphic where the signer's
name is large, as Acrobat's Configure Signature Appearance does: the
saved Fill & Sign signature (drawn, typed or image) or an imported
PNG/JPEG. Sign as previews it and remembers the choice; sign_document
takes `graphic` (image, text or strokes).

pdfcraft-sign gains `Graphic` and `SignOptions::graphic`. Graphics are
checked and size-capped before drawing; images keep alpha as a soft
mask and are encrypted with the rest of the update in encrypted files.

Refs #193

* Refresh catalog hashes

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 13:47:51 -04:00
Wladmir JúniorandBrandon Thomas 626724bbdd pt-BR: lowercase month names in the date picker (#1089)
* pt-BR: lowercase month names in the date picker

* Refresh catalog hashes

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 13:44:40 -04:00
cc344a26de Typed signatures draw Cyrillic and Greek letters (#846) (#963)
Dancing Script, the typed-signature face, is Latin and Vietnamese only, so
script_outline skipped every Cyrillic or Greek character and a Serbian
Cyrillic name came out empty. Characters the script lacks are now drawn from
the bundled Inter Regular, slanted about 12 degrees and scaled to the
script's capital height. Characters neither face has are still skipped.

Inter's bytes are now exported as pdfcraft_fonts::INTER_REGULAR and the UI
uses that static, so the font is still embedded once.

Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 13:20:11 -04:00
LuniiyaandBrandon Thomas 046df9dfe4 UI: theme picker with light and dark theme cards (#1082)
* UI: theme picker with light and dark theme cards

* UI: update the icon count and the theme test for the cycling theme button

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 13:12:47 -04:00
5d301305c9 Issues #444, #186: a mouse wheel scroll speed preference, faster by default on Windows and Linux (#514)
egui scrolls 40 points per wheel line on the desktop. winit reports one line
per notch on Windows and Linux, where browsers scroll about 100 pixels, so
the wheel felt slow there. Preferences ▸ Documents and view gets "Mouse wheel
scroll speed": Slow / Normal / Fast (0.5×, 1×, 2× of the platform's normal:
100 pt per line on Windows and Linux, 40 on macOS and 8 on the web, both
egui's defaults). It is saved, and `set_option("scroll_speed", …)` sets it
over the control channel.

- Applied in `raw_input_hook`, before egui reads the frame's input (and in
  `logic` for hosts without the hook).
- ⌘/Ctrl-wheel zoom keeps egui's rate: those line deltas are scaled back,
  since egui scales lines before turning them into zoom.
- Point deltas (most trackpads) keep their speed; Single page wheel paging
  still turns one page per notch (its notch size is fixed).
- Labels translated (fr, ru, zh-hans, zh-hant, es, ja, bg, de), the speed names in
  a "scroll speed" context; catalog hashes updated.


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

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 13:05:53 -04:00
b1e958cf63 M10: Enhance camera image finds the document's edges; rotate, crop or organize after Enhance page (part 3 of #1008) (#1027)
* M10: enhance camera images: straighten a page from four corners (ocr.camera-perspective)

Four corners on a page (any order, a convex quadrilateral) are warped to an
upright rectangle that replaces what the page shows, read at the page
picture's own resolution (at most 600 dpi) and kept at that physical size.
One undoable step through Replace Pages, so comments, links and bookmarks
stay.

- geom: a pure 3x3 homography solve and corner ordering (no dependencies);
  collinear, concave and non-finite input gives None.
- engine: camera::warp (bilinear, over white, capped at 40 MP) and
  Session::enhance_camera_page.
- automation: page_enhance_camera (corners in top-left-origin points).
- parity: ocr.camera-perspective is partial; the UI handles, automatic edge
  detection and contrast cleanup are still missing.

Refs #1008.

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

* M10: Enhance camera image: drag four corners on the page, then Enhance page (ocr.camera-perspective)

Scan & OCR ▸ Enhance camera image (ocr.enhance_camera) shows four corner
handles on the current page, inset 5% from its edges, with what will be cut
away dimmed. Enhance page straightens what they enclose through
Session::enhance_camera_page (one undoable step); Cancel and Esc leave the
page alone. The buttons float at the top right, clear of a centred page's
corners, and the instructions are a notice, as for Crop.

A command of its own, beside Enhance scanned file (ocr.enhance), which
stays for scan correction (#1012).

- ui-egui: camera_ui (handles, dimming, bar), QuickTool::EnhanceCamera,
  quick_tool "enhance-camera" for ui.state and the shot example.
- Catalogs: the three new labels in every language with UI coverage
  (plus es, pl, zh-hant); ATTRIBUTION.toml hashes updated.
- automation: page_enhance_camera is linked to the command.
- parity: ocr.camera-perspective lists the command and the UI test.

Refs #1008.

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

* M10: Enhance camera image finds the document's edges (ocr.camera-perspective)

The corners now start on the photographed document's edges when they can
be found, else inset 5% as before.

camera_edges::detect_document, pure Rust and deterministic: shrink to 400
px, blur, Sobel edges thinned and thresholded against the 98th percentile;
a Hough transform where each edge votes within 6 degrees of its own
normal; pairs of near-horizontal and near-vertical lines make convex
quadrilaterals (at least 12% of the picture), kept when every side lies on
a matching edge along at least 55% of its length and scored by area times
the square of the mean support; each side is then refitted through its
edge points (total least squares). None when nothing convinces.

- Session::detect_camera_corners (user space); page_enhance_camera finds
  the corners itself when `corners` is omitted and reports the corners it
  used and whether they were detected; the UI command starts from them.
- Tests on synthetic photos: plain table, text on the sheet, wood grain
  with a shadow, strong perspective, a 30 degree turn, a sheet running off
  the picture, no document, white on white, bad input; end to end through
  the engine and the tool.
- Parity notes: what is still missing (turning the result upright, rounded
  corners, clean-up).

Refs #1008.

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

* M10: after Enhance page, rotate, crop or organize the page (ocr.camera-perspective)

Enhance camera image no longer closes on Enhance page: the bar then offers
rotate counterclockwise and clockwise for the enhanced page (one undo step
each), Crop pages and Organize pages (which leave the tool for those
tools), and Done; Esc leaves too. A photo of a card often comes out a
quarter turn off until the page is turned upright automatically.

The two icon buttons are named for screen readers. Every label already
exists in the catalogs.

Refs #1008.

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

---------

Co-authored-by: magdale76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 12:31:23 -04:00
2a5e8e2aa2 M14: correct Taiwan Traditional Chinese accessibility and form terms (#1023)
Correct inverted accessibility wording, pass/fail results, row/column terminology and form-choice labels. Add the missing table-row explanation and regressions tied to rule metadata.

Co-authored-by: OpenAI Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 12:27:39 -04:00
216d9a314e Top bar: the Save button names its shortcut instead of "(M4)" (#962) (#1013)
The Save button's tooltip and accessibility name read "Save (M4)", a
leftover milestone tag, while Print and Document properties beside it
name their shortcuts. It now uses command_tip with file.save, so it reads
"Save (Ctrl+S)" on Windows/Linux and "Save (⌘S)" on macOS. The catalog
key becomes "Save ({key})" in the 15 catalogs that had it (ATTRIBUTION.toml
hashes updated). New test commands::the_top_bar_save_button_names_its_shortcut_not_a_milestone.


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

Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 12:23:55 -04:00
dd065b39bb M4, #569: Combine files takes images and text files (#1032)
* M10: refuse a PNG whose header claims more than 2 GiB of pixels before allocating

The png crate's limits don't cover the caller's frame buffer, which png_image sized from the
header alone: a few-KB PNG claiming 60000x60000 RGBA made Create allocate and fill 14.4 GB.
Apply the 2 GiB ceiling TIFF pages already have. Reached from Create, Insert pages and (with
#569) Combine files.

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

* M4, #569: Combine files takes images and text files

PNG, JPEG, TIFF, GIF, BMP, JPEG 2000 and .txt files become PDFs as they join the Combine files
list (Add files…, drops, folder scans), so each is an ordinary row or card with its pages,
thumbnails and page range. Session::as_pdf does the conversion; PDFs pass through unread
(Combine reads them itself, with their passwords), as does a file named .pdf without a header,
which the reader may still open. doc_combine accepts the same file types, and a converted
file's bookmark drops its extension.

A file that can't be read (a damaged image or PDF, a type that can't be converted) no longer
is refused at the door: it joins the list with why in its Warnings (a red card in the grid).
Combine then asks "Leave out files that can't be read?", naming them; Combine the rest goes on
without them, Cancel changes nothing. Password-protected files still block Combine until
unlocked.

The picker offers "PDF, images and text", and the empty state, folder tooltips and folder
notices say what is accepted; translated in every full catalog (catalog hashes updated).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 12:04:10 -04:00
AlexisandBrandon Thomas b752827162 M7: watermark offset from the page centre (edit.watermark-appearance) (#1076)
Add Watermark / Update Watermark can now move the mark from the page
centre by a horizontal and a vertical offset in points (right and up).

- edit: add_watermark refuses a non-finite offset instead of writing it
  into the content stream; tests for the placement and the refusal.
- automation: doc_watermark takes offset_x and offset_y (clamped to
  +-20,000 points); a test saves, reopens and measures the text moving.
- ui-egui: an "Offset from centre" row with Horizontal and Vertical
  fields; the preview follows it for text and for picture watermarks.
- i18n: "Offset from centre" in the catalogs that have a coverage test
  (ar, bg, de, fr, hu, it, ja, pt-br, ru, uk, zh-hans); hashes updated in
  ATTRIBUTION.toml.
- parity and roadmap: edit.watermark-appearance stays partial (no offset
  from the page edge, no print/screen-only variants, no repeat); image
  and PDF watermarks no longer listed as missing; progress log line.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 11:56:06 -04:00
Brandon ThomasandClaude Opus 5.5 bbbe60015c M10: Windows printing prints at actual size, off the UI thread (follow-up to #847) (#1007)
* M10: Windows printing prints at actual size, off the UI thread (follow-up to #847)

- Sheets print at 100% on the driver's paper closest to their size, landscape
  when wide, instead of being shrunk into .NET's default 1-inch margins
  (about 76% on Letter) and never rotated. Each sheet is capped at ~64 MP so a
  poster-sized sheet can't exhaust memory.
- PowerShell runs with CREATE_NO_WINDOW: the release app has no console, so
  Windows opened one over the app for the whole job.
- The Print dialog lists printers in the background (the desktop app's
  printer_source) and spools jobs on a worker thread, so Ctrl+P and long jobs
  no longer freeze the UI. A list arriving late preselects the default only if
  the user hasn't picked a printer.
- Parity evidence named a test that #847 renamed; xtask parity passes again.
  The raster-only limit is now stated in the parity notes and README.

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

* M10: refresh catalog hashes in ATTRIBUTION.toml for the new print strings

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 01:15:23 -04:00
skylar taylor-barrick ecd9d158ea UI: preserve shared document workspace on current main 2026-10-10 19:10:14 -06:00
skylar taylor-barrick 8ff27dc86e UI.9: preserve PDFCraft panels and document lifecycle 2026-10-10 15:46:23 -06:00
82b1093b73 M7: select and move mixed PDF objects together (#844) (#907)
Preserve source glyphs and artwork, resolve the group atomically, and expose generation-scoped automation tools with selection and rendering regressions.

Co-authored-by: Tevinhead <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 17:38:34 -04:00
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 <[email protected]>

* Refresh catalog hashes after merging main

---------

Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
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 <[email protected]>

* 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 <[email protected]>

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

---------

Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

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

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

* Format

---------

Co-authored-by: Brandon Thomas <[email protected]>

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

* M13: preserve desktop Action Wizard batch results

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

* ROADMAP: date the desktop action outputs entry today

---------

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

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

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

Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>

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

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

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

---------

Co-authored-by: Tristan von Kries <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Haiku 5.5 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
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 <[email protected]>

---------

Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

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

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

* 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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

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

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

* Format

---------

Co-authored-by: Brandon Thomas <[email protected]>

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

* M13: preserve desktop Action Wizard batch results

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

* ROADMAP: date the desktop action outputs entry today

---------

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

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

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

Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>

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

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

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

---------

Co-authored-by: Tristan von Kries <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Haiku 5.5 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
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 <[email protected]>

---------

Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

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

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

* 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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>

---------

Signed-off-by: Hadi Chokr <[email protected]>
Co-authored-by: Andrew Taylor <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: MarekSpodBiedry <[email protected]>
Co-authored-by: muraa-p <[email protected]>
Co-authored-by: Big Pickle <[email protected]>
Co-authored-by: mlkonrad <[email protected]>
Co-authored-by: Tristan von Kries <[email protected]>
Co-authored-by: Tristan von Kries <[email protected]>
Co-authored-by: Krzysztof Cieślik <[email protected]>
Co-authored-by: Nigel Ihza Firdausy <[email protected]>
Co-authored-by: Tevinhead <[email protected]>
Co-authored-by: Tevinhead <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: snipeTR <[email protected]>
Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Justin John <[email protected]>
Co-authored-by: Justin John <[email protected]>
Co-authored-by: cyfung1031 <[email protected]>
Co-authored-by: 才倾梦华V <[email protected]>
Co-authored-by: Ikki Nishihara <[email protected]>
Co-authored-by: gyurix <[email protected]>
Co-authored-by: GyuriX <[email protected]>
Co-authored-by: LloydNicholson <[email protected]>
Co-authored-by: lloyd <[email protected]>
Co-authored-by: CyberFox-81 <[email protected]>
Co-authored-by: Aviral Bansal <[email protected]>
Co-authored-by: Epsilon-LLC <[email protected]>
Co-authored-by: Silver <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Andrew Taylor <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: MarekSpodBiedry <[email protected]>
Co-authored-by: muraa-p <[email protected]>
Co-authored-by: Big Pickle <[email protected]>
Co-authored-by: mlkonrad <[email protected]>
Co-authored-by: Tristan von Kries <[email protected]>
Co-authored-by: Tristan von Kries <[email protected]>
Co-authored-by: Krzysztof Cieślik <[email protected]>
Co-authored-by: Nigel Ihza Firdausy <[email protected]>
Co-authored-by: Tevinhead <[email protected]>
Co-authored-by: Tevinhead <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: snipeTR <[email protected]>
Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Justin John <[email protected]>
Co-authored-by: Justin John <[email protected]>
Co-authored-by: cyfung1031 <[email protected]>
Co-authored-by: 才倾梦华V <[email protected]>
Co-authored-by: Ikki Nishihara <[email protected]>
Co-authored-by: gyurix <[email protected]>
Co-authored-by: GyuriX <[email protected]>
Co-authored-by: LloydNicholson <[email protected]>
Co-authored-by: lloyd <[email protected]>
Co-authored-by: CyberFox-81 <[email protected]>
Co-authored-by: Aviral Bansal <[email protected]>
Co-authored-by: Epsilon-LLC <[email protected]>
Co-authored-by: Silver <[email protected]>
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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

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

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

* Format

---------

Co-authored-by: Brandon Thomas <[email protected]>

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

* M13: preserve desktop Action Wizard batch results

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

* ROADMAP: date the desktop action outputs entry today

---------

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

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

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

Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>

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

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

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

---------

Co-authored-by: Tristan von Kries <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Haiku 5.5 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
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 <[email protected]>

---------

Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

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

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

* 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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* 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 <[email protected]>

---------

Signed-off-by: Hadi Chokr <[email protected]>
Co-authored-by: Andrew Taylor <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: MarekSpodBiedry <[email protected]>
Co-authored-by: muraa-p <[email protected]>
Co-authored-by: Big Pickle <[email protected]>
Co-authored-by: mlkonrad <[email protected]>
Co-authored-by: Tristan von Kries <[email protected]>
Co-authored-by: Tristan von Kries <[email protected]>
Co-authored-by: Krzysztof Cieślik <[email protected]>
Co-authored-by: Nigel Ihza Firdausy <[email protected]>
Co-authored-by: Tevinhead <[email protected]>
Co-authored-by: Tevinhead <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: snipeTR <[email protected]>
Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Justin John <[email protected]>
Co-authored-by: Justin John <[email protected]>
Co-authored-by: cyfung1031 <[email protected]>
Co-authored-by: 才倾梦华V <[email protected]>
Co-authored-by: Ikki Nishihara <[email protected]>
Co-authored-by: gyurix <[email protected]>
Co-authored-by: GyuriX <[email protected]>
Co-authored-by: LloydNicholson <[email protected]>
Co-authored-by: lloyd <[email protected]>
Co-authored-by: CyberFox-81 <[email protected]>
Co-authored-by: Aviral Bansal <[email protected]>
Co-authored-by: Epsilon-LLC <[email protected]>
Co-authored-by: Silver <[email protected]>
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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 16:41:27 -04:00
2a02d5ddab M14: localize displayed shortcut key names (#496) (#884)
Co-authored-by: Tevinhead <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
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 <[email protected]>
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 <[email protected]>

---------

Co-authored-by: DWAA1660 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
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 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
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 <[email protected]>

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

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
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 <[email protected]>

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
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 <[email protected]>

* 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 <[email protected]>

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

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

---------

Co-authored-by: Tevinhead <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
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 <[email protected]>

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: William Max & Co. <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 18:56:45 -04:00