* 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]>
* 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]>
* 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]>
* 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]>
* 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]>
* 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]>
* M3: add cursor zoom and temporary mouse-button navigation on Windows
Anchor Ctrl-wheel zoom to the cursor; share bounded, immediate ScrollArea pan between the middle button and Space + primary drag. Preserve ordinary scrolling and editing, cancel capture on focus/document changes, and initialize numeric zoom before the viewport exists.
Validation on Windows x64: formatting, application check and workspace Clippy pass; full workspace tests pass (928 passed, 4 ignored), including 53 UI unit tests and 27 control integration tests. No branding changes, new dependencies, machine-specific build configuration or local development notes.
* M3: add contributor display name for mhadidi06
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* 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]>
* Give dialogs roomier text fields and padding
Single-line fields in dialogs and side panels were only as tall as one line of text, and modals used the popup frame's tight margin. Fields are now 30 points with more inset, and every modal shares one padded card so titles and buttons sit clear of the edge.
* Keep long labels inside their rows and dialogs
Painted names ran past the edge of fixed rows, and a page hover tip was drawn above open dialogs. Labels now end in an ellipsis, tab rows wrap, and those tips stay hidden while a dialog is open.
---------
Co-authored-by: Brandon Thomas <[email protected]>
* 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]>
* fix(theme): complete Linux System appearance tracking
Follow up on #881 for #446 with the standard numeric appearance portal setting
and event-driven updates that wake idle windows. Keep manual modes independent,
cache their desktop updates without repainting, and cancel the worker on close.
Retain inherited native window appearance and populate both egui style branches
with the chosen palette. Preserve saved System, the Light default and unknown
source fallback, and avoid desktop session access in headless UI contexts.
Assisted-by: Codex (gpt-6.1-sol)
* Desktop theme: keep the GNOME colour-scheme fallback XFCE needs
The portal tracker read only org.freedesktop.appearance, which XFCE's portal
doesn't answer (it proxies the GSettings schemas). It now also reads and
subscribes to org.gnome.desktop.interface color-scheme and uses it when the
freedesktop setting states no preference, as main's reader did. KDE answers
only the freedesktop one, which wins when it states a preference.
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]>
* tests: consolidate safe ui-egui integration targets
* Group the safe UI tests as tests/ui_group/ instead of turning off test discovery
With autotests = false, a test file added later builds only if someone also lists it in
Cargo.toml, so new tests could silently never run. A tests/ui_group/main.rs target groups the
same 15 files while Cargo keeps discovering every other file in tests/.
---------
Co-authored-by: Brandon Thomas <[email protected]>
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#995Closes#1077
Co-authored-by: Brandon Thomas <[email protected]>
* Forms: keep tiles and render unbaked field values during page re-render
In complex interactive forms, switching focus away from an edited field
caused its text to disappear for several seconds while background tiles
re-rendered.
- In DocView, retain existing tiles as stale rather than immediately
purging them on page_changed so the canvas remains sharp without flashing.
- Prioritize visible tiles over backdrops in the render queue.
- Track unbaked form fields and paint an immediate egui widget preview
for committed text, checkbox, and radio inputs until fresh raster
tiles arrive.
- Scope page invalidation on form edits to pages containing the edited
or calculated fields.
- Hoist fields lookup in recalculate_with and apply_changes to eliminate
repeated parsing.
- Run JavaScript scripts via catch_unwind on the current thread instead of
spawning a new thread per script invocation.
- Add contributor entry for Piohrun in contributors/people.toml.
- Add test switching_fields_keeps_committed_text_visible_immediately.
* Forms: address review feedback on tile stale tracking, hoist, and diff invalidation
- Revert crates/js to preserve dedicated 1 GiB stack thread for boa scripts.
- In DocView::page_changed, purge non-current-tag tiles and mark only current-tag tiles stale.
- Restore demand queue order in prepare_render_queue.
- Add unbaked overlay entries only on successful edit apply and only for fields whose values changed; fix font zoom calculation on 90°/270° pages.
- Re-read fields(doc) in recalculate_with only when script returns changes.
- Diff form before and after edit in apply_edit, invalidating pages with changed fields, falling back to document_changed for XFA or page-count changes.
- Add tests covering script recalculation side effects across pages, unbaked overlay cleanup on tile delivery/zoom/rejection, and checkbox recalc chaining.
---------
Co-authored-by: Brandon Thomas <[email protected]>
* Fill & Sign: scale down large signature images instead of rejecting them (#1075)
A 6025 x 2506 PNG signature failed with "Image size exceeds limit": imports
were capped at 4096 px per side and 4 megapixels, and the decoder's limit
error leaked through raw. Sources up to 8192 px per side and 32 megapixels
are now decoded and scaled down to fit the old bounds; anything larger
reports the normal size message.
Reported-by: @HassanAlthaf
Co-authored-by: mjohnson7cd <[email protected]>
* A placed image too large to preview gets its own message; scale with thumbnail, which needs far less memory
---------
Co-authored-by: mjohnson7cd <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* fix(create): cap decoded size of imported PNGs
A PNG's declared dimensions sized an infallible allocation, so a few-byte file claiming huge dimensions aborted the process when the allocation failed. Cap the decoded size at the TIFF ceiling and reserve every input-sized buffer fallibly, returning CreateError::Image instead. (Security audit run-2: v3-024.)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(xfa): bound total picture bytes kept during layout
Each laid-out picture kept its own copy, so one picture in a repeating subform multiplied without bound and could exhaust memory on open. Add a per-layout aggregate cap (256 MiB) that stops with XfaError::TooLarge. (Security audit run-2: v3-103.)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(xfdf): bound XML nesting and protect redaction marks on import
Deeply nested XFDF/XML overflowed roxmltree's recursive parser and aborted the process; a non-recursive depth scan now rejects it before parsing. Imported comments could also replace a locked or redaction mark by /NM or add a hidden one; place() no longer replaces locked/Redact annotations and clears the hidden/invisible flags on imported redaction marks. (Security audit run-2: v3-105, v3-111.)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(sign): classify post-signing changes by the signed revision's roles
A signed object's modification was classified from labels in the attacker-written new version, so relabelling a signed page /Type /Sig (or giving it /ModDate), or relabelling a drawn stream /ObjStm or /XRef, let a forged page validate as Valid even under DocMDP P=1. Decide an object's role (page content, signature, metadata, file plumbing) from what it was in the signed revision, not its new dictionary. (Security audit run-2: v3-082, v3-083.)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(redact): remove redacted content from the saved file, fail closed otherwise
Apply Redactions and Remove Hidden Information left the originals behind: dropped or cleaned image/form XObjects kept their resource names (so the full rewrite saved the originals), an XFA form kept the redacted value in its datasets packet, and vertical-writing text under a mark was never hit by the horizontal removal geometry yet reported success. Prune replaced XObject names the page no longer draws, drop /XFA and /NeedsRendering when redacting an XFA form, and fail closed (RedactError::VerticalText) rather than silently leave vertical text. (Security audit run-2: v3-064, v3-067, v3-062.)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(ui-egui): trust the displayed signer, not the document's embedded root
The Signatures panel's one-click 'Add to trusted certificates' trusted the top of the chain the document supplied, installing the author's self-signed root as an anchor instead of the signer shown. Trust the displayed signer certificate. (Security audit run-2: v3-098.)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* Use main's decoded-size ceiling for PNGs too; keep the depth tests on main's checker
---------
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* 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]>
* 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]>
* Cap editing and text-layer caches that grew without bound
The cos editing cache kept every touched object until save, so editing
a large document pinned its whole object graph. Bound it like the scan
reader: 1024 objects / 32 MB of owned bytes, 256 object streams /
32 MB, evicting to the newcomer. Returned objects outlive eviction, and
clones share the caches, so this only costs re-parsing.
The viewer text cache had the same shape: every visited page's text
stayed for the session. Retain visible, selected and matched pages plus
a 24-page prefetch window each frame; evicted pages re-extract on
revisit. Skipped while a search is active, where demand still wants
every page. Failures are never evicted: they cost nothing to remember.
cli info on a 1024-page/147 KB file (debug build, same host/command):
before 66.7 MB RSS, after 54.0 MB RSS (3 runs, tight).
Tests: editor count bound, byte bound, snapshot-clone reads (cos);
evict-far/keep-visible+matched+selection, search-active keep-all
(canvas). All mutation-checked: each bound goes red when disabled.
Signed-off-by: Chiranjeevi U <[email protected]>
* Index form fields once per refresh instead of once per field
The form-scope refresh ran a linear find over all fields for every
field, O(F^2) on each keystroke; forms reach 10k fields. Build one
name index up front, O(F). entry().or_insert() keeps the old
first-match semantics for repeated field names.
New test with two same-named widgets pins first-wins and goes red
under insert. 141 engine tests green, clippy warnings and perf lints
clean.
Signed-off-by: Chiranjeevi U <[email protected]>
---------
Signed-off-by: Chiranjeevi U <[email protected]>
Acrobat saves the line breaks in a comment's /Contents as CR, e.g.
"first line\r\rsecond paragraph". egui draws CR as nothing and only breaks
lines at LF, so the comments panel, the hover popup and the edit box ran
the paragraphs together ("first linesecond paragraph").
Both readers of comment text (pdfcraft_render::inspect on open and
pdfcraft_annot::summaries after an edit) now turn CRLF and CR into LF.
Regression tests: carriage_returns_in_comment_text_become_line_breaks in
annot and render (both fail before this change).
`powershell_command` passed the bare name "powershell" to
`std::process::Command::new`. On Windows, Rust resolves a bare name in the
directory of the running executable before System32
(https://doc.rust-lang.org/std/process/struct.Command.html, "Platform-specific
behavior"), so a powershell.exe sitting beside a portable pdfcraft.exe ran
instead of Windows PowerShell, for every printer list and every print job.
Seen in Windows Sandbox against bbbe600 with a harmless stand-in exe: the
Print dialog listed the stand-in's output as the printer and Print "sent" to
it.
Name it by its full path under %SystemRoot%, the way `open_printer_preferences`
already names rundll32 in the same file. The same Sandbox test against this
build: the stand-in beside the app never runs, the real printer is listed and
the job spools.
Regression test: `windows_starts_powershell_by_its_full_path` (red with the
bare name, green with the fix). Not in any release yet: #847 is on main only.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Linux: the tab strip carries the window controls, as in Acrobat
On X11 and Wayland the window manager drew its own title bar above the tab
strip, so every PdfCraft window had two bars and the controls the user clicked
were the window manager's, not ours. `ViewportBuilder::with_decorations(false)`
turns the frame off, and `chrome::window_frame` draws minimize, maximize and
close at the top right of the tab strip, plus a hairline border and resizing
from the edges and corners, so a frameless window behaves like a framed one.
Close still goes through the unsaved-changes check, and full screen and the
in-app full-screen page view draw no controls over the page.
Drawing it last keeps the controls reachable from every overlay that covers
the tab strip. `PDFCRAFT_SYSTEM_TITLEBAR=1` puts the window manager's frame
back for anyone who wants it.
* Render visible pages before thumbnails and text extraction
Opening a document queued its visible page pixels together with
Pages-panel thumbnails and background text extraction, so the render
workers spent the first seconds on background work while the visible
page stayed blank. Hold thumbnails and text until no visible main-page
raster is still missing; errored pages never block.
* Render visible pages before thumbnails and text extraction
Opening a document queued its visible page pixels together with
Pages-panel thumbnails and background text extraction, so the render
workers spent the first seconds on background work while the visible
page stayed blank. Hold thumbnails and text until no visible main-page
raster is still missing; errored pages never block.
---------
Co-authored-by: GyuriX <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Text editor on the page keeps a dark caret on its white box in the dark theme (#655)
* Form fields, typed fill text and added text keep a dark caret on their white boxes in the dark theme (#655)
---------
Co-authored-by: Brandon Thomas <[email protected]>
* Rework faux-redaction audit per maintainer review
- Paint-order aware: a rectangle counts as a cover only when painted
strictly after the text (positions from the joined content stream).
- Colour-aware: WCAG contrast between text and cover must be below 3.0,
so page backgrounds, table shading and highlights are not flagged.
- Fail closed: audit_redactions returns Err on undecodable content
streams (decoded_strict) and malformed /Annots; the operator bound is
an error, not a silent truncation.
- Graphics state carries across a page's content streams (one joined
walk); form XObjects still isolate their state at Do.
- Audit-only: the sanitize-for-sharing half is dropped (upstream #162).
- Parity entry marked partial, not shipped.
* Audit: register its layer, add a README, fail closed past 32 nested forms, document what it doesn't check
---------
Co-authored-by: Erick Barraza <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* fix(annot): validate finite link areas before add or edit
* Test: find the edited link by its /Annots index, not its position in the list
---------
Co-authored-by: Brandon Thomas <[email protected]>
* 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]>
* Open dialog: allow selecting several files at once
* add contribution in people.toml
* people.toml: restore arcanii and atoslins (lost in a merge); key the new entry by GitHub login
---------
Co-authored-by: Brandon Thomas <[email protected]>
* M14: Scan PdfCraft page-content markers once on open
Combine the engine-open marks and added-content discovery passes while keeping the existing public scans and document state unchanged. Add scanner equivalence and engine-open coverage, and retain the local Clippy binding needed for the workspace gate.
Co-authored-by: Codex <[email protected]>
* M14: reuse the combined marker scan on full refresh
Keep full refresh from repeating the separate marks and added-content traversals, with a mark_saved regression against the independent scans.
Co-authored-by: Codex <[email protected]>
* M14: scan marker metadata without cloning content containers
Borrow direct content objects and keep referenced objects alive during marker inspection. This avoids copying /Contents arrays and /PCAdded dictionaries for the combined read-only scan while preserving one-reference resolution and content-object identity.
Co-authored-by: Codex <[email protected]>
* M14: record combined marker scan measurements
Update the #307 progress entry with full-refresh reuse, the borrowed scan implementation, and exact before/after timing and memory measurements.
Co-authored-by: Codex <[email protected]>
* M14: align marker measurements with final benchmark run
Replace the preliminary values with the final exact-revision alternating-run medians and corresponding pair counts.
Co-authored-by: Codex <[email protected]>
* marks_present and list_added read through scan_markers, so the parsing lives in one place
---------
Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Select tool: trigger multi-click text selection on pointer press (#1024)
- Detect consecutive primary pointer presses on page text using press timing
and distance thresholds, highlighting words, lines, or the whole page
immediately on the down-stroke rather than waiting for pointer release.
- Keep selection intact on release for multi-clicks while allowing single clicks
to clear or shift-extend.
- Add regression test `multiclick_selects_on_pointer_press_before_release` in
`crates/ui-egui/tests/text_select.rs`.
- Credit krinry in contributors/people.toml.
* Count presses with egui's own double-click delay and distance
---------
Co-authored-by: Brandon Thomas <[email protected]>
* 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]>
* Viewer: touchpad scrolling tracks the finger 1:1 (#759)
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: Claude Opus 4.8 <[email protected]>
* Viewer: Continuous scrolling is an independent checkbox over the base layout (#752)
Page display matches Acrobat: the base layout (Single page or Two-page
view) is a radio choice, and Continuous scrolling is a separate checkbox,
so every combination is reachable. This also adds the previously missing
"Two-page view, one spread at a time" mode.
`PageLayout` now has just `Single` and `TwoUp`; a separate `continuous`
flag on the view (and on the default view preferences) decides whether it
scrolls. A single `display_token` ("single" | "continuous" | "two-up" |
"two-page") serialises both for settings and the control channel, keeping
the earlier `continuous`/`single`/`two-up` names working and adding
`two-page` for the new mode. The PDF's own page-layout request now maps
to the right (layout, continuous) pair, including plain TwoUp.
- View ▸ Page display and the rail menu show two radios plus a Continuous
scrolling checkbox; Preferences gains a default Continuous checkbox.
- `view.continuous` toggles the checkbox; `view.layout.single` /
`view.layout.two_up` switch the base layout without touching it. The
former `view.layout.continuous` command is gone (its state is Single +
continuous); the `layout`/`default-layout` options and a new
`continuous` option cover scripting.
- Not scrolling, the wheel and the arrow/Page keys turn whole spreads in
Two-page view, and only the current page or spread is laid out.
Tests: new `notches_turn_two_page_spreads`,
`two_page_view_shows_one_spread_at_a_time_until_continuous`, and updated
layout/command/persistence tests; the `view()` helper takes `continuous`.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* Parity: the continuous-scrolling feature points at view.continuous
---------
Co-authored-by: Justin John <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* 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]>
* 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]>
* M2: overlapping text runs stay whole, laid out in arrange
Two text runs that overprint each other on one baseline were merged into one segment and sorted by x, so their letters came out interleaved ("ADleplhtaa ebpestialo gna zmemtaa"). Runs are now ranges of the filtered glyph list that arrange() already works on. Glyphs of runs that collide (overlap by more than a quarter of the line height) stay whole and are placed by their left edges; runs that only interleave along the line keep glyph-by-glyph order. Glyph placement happens when a segment is emitted, into scratch storage that every segment reuses.
layout() is unchanged: it still calls arrange() and takes the original glyphs by the returned indices. Turned text needs no separate path, because arrange_flow() already lays it out in its own baseline frame.
Tests: overprinted runs at 0 and 37 degrees, a run drawn over a line, a combining mark drawn last, and runs that only touch stay as they were or come out whole.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: a run that redraws an earlier run is printed once
Doubled, shadowed or fake-bold text draws a whole line again at (almost) the same place. The per-glyph fake-bold filter only looks four glyphs back, so a redraw of a whole run got through and printed twice.
After runs are identified, a run is dropped when an earlier retained run has the same glyph texts over the same places (each glyph within a fifth of its height). Runs are keyed by a fingerprint of their glyph texts, with each text hashed with its length so glyph boundaries count, and by a grid cell. Every earlier candidate with the same key is checked, so a run that does not match cannot hide a later redraw. Dropping a run compacts the segment list only; glyphs are never removed from the input, so the returned indices are still mapped through the fake-bold filter's `kept`.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: merge zero-width marks into a segment in one pass
Each zero-width mark was inserted with a search from the start of the placed glyphs and a Vec::insert, so a segment of m marks and n glyphs cost O(m·n) comparisons and shifts. Marks are visited in x order, and the position each one goes to never moves back, so a single cursor over the placed glyphs puts them all in. The output is unchanged, including when a colliding component leaves the placed order non-monotonic in x, because the cursor stops at the same first glyph whose x is greater than the mark's.
A 16000-mark segment takes 225 ms, the same as main (608 ms with the per-mark insert).
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: a dense overprint joins its runs instead of sweeping every pair
The collision sweep compared each glyph with every later glyph that started before its right edge. When many glyphs start at one x, as in a heavily overprinted line, that is quadratic in the glyphs of the segment.
The sweep now has a budget of COLLIDE_PAIRS_PER_GLYPH (16) pair tests per glyph of the segment. Ordinary text overlaps each glyph with about one neighbour, so it never reaches the budget and its output is unchanged. A segment that needs more is dense: its text runs are joined into one component and placed run by run, which keeps every run whole. The fallback is one pass over the runs, so the work for a page is O(glyphs) pair tests plus the sorts.
On a page of 2000 overprinting runs (12000 glyphs) the sweep took 157 ms, against 53 ms on main; with the budget it takes 65 ms. The output is identical to the sweep's, since every run in these pages overprints every other.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: look up forced word breaks by binary search
Each glyph of a segment was checked against every forced word break with breaks.contains(), so a segment with many overlapping rows cost O(glyphs x breaks). The breaks are sorted once, after collision placement, and looked up by binary search.
The lookup returns the same answers, so the output is unchanged. The new test has 60 overlapping rows, each of which starts a word.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: find each word gap's glyph by a position table, not a scan
For every word gap, the glyph that takes the space was located by scanning the segment's output (idx.iter().position), so a long line with many gaps cost O(glyphs x gaps). This was already on main; the change makes it linear.
Each glyph's place in its segment's output is now written into a page-sized table as the segment is emitted, and the gap reads it. The table is never cleared: a segment writes every entry it reads before reading it, and the glyphs of a segment are a permutation of the same set it wrote. The space goes to the same glyph as before, so the output is unchanged.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: check a redraw against at most 32 earlier runs
A run is checked against every earlier run with the same fingerprint and grid cell. Runs that share a key but do not redraw each other are all kept in that chain, so a page of N such runs costs O(N²) checks. The chain is new in this change, not in main.
Each run now checks at most REDRAW_CANDIDATES (32) candidates, newest first. A run past the limit is kept, as it would be if no earlier run matched. Runs that redraw one of the newest candidates are still dropped, and a test checks both sides of the limit: a redraw of the newest of 40 look-alike runs is found, and a redraw of the oldest is kept.
On 4096 non-redrawing runs (69 632 glyphs, all kept by both versions) the check cost 200 ms, and the bound brings the page back to 286 ms, against 272 ms on main. Output is identical.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
---------
Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* xfa, xfdf: refuse XML nested past 1024 levels (roxmltree recurses with nesting depth)
roxmltree parses by recursive descent, so its stack use grows with the nesting depth of the
document it is handed. Ten thousand nested elements overflow the main thread's stack and abort
the process ("fatal runtime error: stack overflow, aborting", SIGABRT, core dumped); three
thousand still parse on the default stack. A stack overflow is `abort()`, so `catch_unwind`
cannot intercept it and the whole application dies.
Two of our XML entry points hand attacker-supplied text straight to it:
- XFDF/FDF form data import. A 1.4 MB file with 50,000 nested `<field>` elements aborted
`pdfcraft-cli run doc_import_data` in 288 ms (SIGABRT, core dumped). The same file now returns
"the file nests XML elements 50003 deep, past the 1024 the reader accepts" in 54 ms, at 19 MB.
- The XFA template parser (`crates/xfa/src/parse.rs`), reached when a document with an XFA form
is opened or laid out. A 10,000-deep template aborted this crate's own test run before the fix.
`crates/xfa/src/data.rs` parses the datasets packet and the data written back into it, so it is
guarded the same way.
The bound has to be applied before parsing, not while walking the result: the overflow happens
inside the parser, so a depth cap on our own walks (64, unchanged) cannot prevent it. Both
crates measure the nesting with a small scanner that skips comments, CDATA, processing
instructions, the DOCTYPE and quoted attribute values, and counts a tag that closes itself
(`<draw/>`) as one level while it is read rather than as a level left open, so a template full of
styling elements is two levels deep rather than two thousand. It cannot under-count a real
element; a stray `<` in text is invalid XML and only makes it over-count, so it errs towards
refusing a file rather than trusting it.
The limit is 1024 because of measurement, not taste: 3000 levels still parse on the default stack
and 10000 abort, and `hostile_templates_fail_without_panicking` keeps asserting that a 500-deep
template parses, so the bound has to sit well above that.
Tests: `deeply_nested_fields_are_refused_instead_of_overflowing_the_stack` and
`deeply_nested_template_is_refused_instead_of_overflowing_the_stack` both abort with a stack
overflow before this change and pass after it; `nesting_depth_scanner_counts_what_matters` and
`self_closing_elements_do_not_accumulate_in_the_depth` cover the scanner (comments, CDATA, a
quoted attribute holding `<`, and 2,000 sibling `<draw/>` elements, which a scanner that leaves
self-closing tags open counts as 2,000 deep);
`many_self_closing_elements_do_not_accumulate_into_the_bound` parses a template holding two
thousand of them; and `nested_fields_within_the_limit_still_read` proves a 16-level field tree
still reads with the same dotted names. Both suites pass: xfa 36, xfdf 18.
The parse options also carry `nodes_limit` now, which was already the shape the XFA parser used:
a 93 MB XFDF with two million sibling elements went from 1082 MB and 2323 ms to 289 MB and
1299 ms ("nodes limit reached").
The scanner is duplicated between the two crates because nothing between them owns XML. If it
should live in one place, say where and it moves.
* xfa: the depth limit holds on a default-stack worker thread
---------
Co-authored-by: Brandon Thomas <[email protected]>
* render: don't let a hostile page-tree /Count abort inspection
Found by cargo xtask fuzz on Windows (seed 42): a 355-byte PDF whose
/Type /Pages node carries /Count 4294967295 aborted pdfcraft-cli
check-one with a 48 GiB allocation (4294967295 x 12 bytes) - an abort,
which the engine's catch_unwind guard cannot catch. AGENTS.md §4
requires a malformed PDF to produce an actable error, never a crash.
The lopdf Structure impl delegated get_pages to lopdf's own page-tree
walk, which trusts /Count and pre-allocates by it. The trait's default
walk (depth cap, seen-set, checked page numbering) handles the same
input fine, so the override is simply dropped and the bounded walk
counts the real pages.
Comes with a synthetic regression test (/Count 4294967295, one real
page); cargo test -p pdfcraft-render, clippy -D warnings, fmt --check
and an end-to-end check-one run all pass.
* Drop the duplicated use super::* in the structure tests
---------
Co-authored-by: pechnikov2026-star <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* fix(ui): preserve scroll position when collapsing or resizing sidebars (#1056)
* Run cargo fmt
---------
Co-authored-by: Brandon Thomas <[email protected]>
Release builds have no Simplified Chinese face: craft-fonts' only Hans face
is Noto Sans CJK SC, which build.rs skips (AGENTS.md §1.1). The Chinese UI
therefore fell through to BIZ UDPGothic, which lacks 226 of the 771 Han
characters in zh-hans.tsv (欢, 导, 签, 编, 创 ...) and 12 in zh-hant.tsv.
In a Chinese interface built without a Hans face, the desktop app now
loads one installed Chinese face (Microsoft YaHei/JhengHei, PingFang,
Hiragino Sans GB, STHeiti, WenQuanYi, Droid Sans Fallback; never Noto CJK
or Source Han) and places it before the Japanese faces so a line keeps one
face and one baseline. Other languages keep the existing Arabic-capable
fallback, last in every family. Still one installed font, read at runtime,
never embedded; PDFCRAFT_SYSTEM_FONTS=0 still turns it off.
Claude-Session: https://claude.ai/code/session_01Rsrc3tE4vB8UrMazUAkSkU
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Outlook opens an attachment from its own cache (INetCache\Content.Outlook\<random>\ for
classic Outlook, the Microsoft.OutlookForWindows package folder for the new one). Save wrote
back to that copy: it either fails because Outlook still holds the file, or appears to work
while the edits never reach the email and are deleted with the cache.
Save on such a file now opens the Save As dialog, as for a new document. After that the chosen
file is the document's file and Save writes it directly. Save As, autosave and the agent tools
are unchanged.
* test(xtask): add measure geometry fuzz target
* fix(xtask): cap dense measure fuzz seed
* docs: update robustness audit log
* test(measure): check snap geometry bounds in measure integration tests
Adds crates/measure/tests/snap_property.rs with two deterministic #[test] cases: a fixed-seed, 1,000-case mutation check of geometry extraction and snapping, and a high-curvature regression that asserts the segment cap is reported. Panics are not caught, so a panic, abort or stack overflow ends the test process and cargo test reports it.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* chore(xtask): drop fuzz-measure command and its ROADMAP entry
The measure property test replaces the xtask command. Removes xtask/src/fuzz_measure.rs, its registration, the xtask dependencies on pdfcraft-content, pdfcraft-cos and pdfcraft-measure (and their Cargo.lock edges), and the robustness-audit ROADMAP paragraph, which describes other PRs.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
---------
Co-authored-by: Claude Haiku 5.5 <[email protected]>
* docs: register the MCP server from an installed release
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: drop the third-party link; the Windows path is the default install folder
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: drop the third-party link; the Windows path is the default install folder
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: drop the third-party link; the Windows path is the default install folder
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Fix#624: preserve hard edges for non-interpolated images
Select nearest-neighbour for Hayro's image pre-minification when /Interpolate is absent or false, retaining Catmull-Rom for true. Add a synthetic regression covering default, false, and true behaviour and record the vendored patch.
Co-authored-by: Codex <[email protected]>
* M2: test non-interpolated minification with exact colours
The #624 regression used alternating black and white columns. Nearest 8→4 sampling reads only white columns from that pattern, so an image skipped entirely still passed, and the check only asked for near-black or near-white pixels.
Replace it with 2×2 colour blocks whose exact device output is asserted, with /Interpolate omitted equal to false, and /Interpolate true still blending. Add exact cases for gray, a non-integer 10→6 minification, and a matched soft mask. All pass on this head. On origin/main the same block image renders with blended mid-tones (for example 12eb12ff where pure green is expected), so these tests fail without the #624 change.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: plan nearest-neighbour pre-resize without filter limits
Non-interpolated images are pre-resized with nearest-neighbour, which copies source pixels and needs no filter tables, crossed intermediate or scratch. The preflight still applied the Catmull-Rom checks to it: a source wider than 2^20 pixels, or a filter plan over 128 MiB, was refused, and once such a source was wider than the backend's u16 limit the image was skipped.
resampling_byte_len and image_resampling_size take the image's interpolation flag. Nearest keeps the source and target buffer bounds and refuses any scratch it reports. Filtered images keep their limits unchanged, and the existing image_resampling_size tests pass true.
Measured: a 1.5M-pixel-wide RGB row drawn at 750×4 device pixels renders 0 non-white pixels on origin/main (the image is skipped) and all 3000 pixels with this commit. The 1.1M-pixel gray case is covered by wide_non_interpolated_sources_draw_after_shrinking.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: keep non-interpolated Type 3 glyph images nearest
Minified images inside Type 3 glyphs were drawn with ImageQuality::High, a bicubic sampler, whatever their /Interpolate. A non-interpolated image had already been pre-resized with nearest-neighbour, and bicubic sampling of that result blended it again. High is now used only for interpolated images, as elsewhere.
Before this commit the new test saw an interior glyph pixel of [28, 28, 27] (not a palette colour) for a 4.6 × 2.3 px glyph of a palette image; after it, every interior pixel is a palette colour. /Interpolate true still blends.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: nearest-sample masked minified images straight into premultiplied RGBA
A non-interpolated image with a matching soft mask used to build a full-resolution RGBA copy, nearest-resize it, then premultiply the target. For a minified image most of that copy was discarded.
nearest_premultiplied_rgba reads colour and mask from the same source pixel, selected with PicScale's nearest index (same fixed-point centre sampling as ResampleNearestPlan) and premultiplies only the target pixels. It falls back to the previous path if its target buffer cannot be allocated. Interpolated images, unmasked images and magnification are unchanged.
New test: a ten-column gray ramp with a soft mask drawn six pixels wide must pick the same columns for colour and mask.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
* M2: record the #624 image patch in vendor/README.md
The hayro row listed the #624 regression test but not the patch itself. Add patch item (10) covering the nearest pre-resize and its planning, the Type 3 bicubic restriction and the premultiplied soft-mask path, and list every regression test that covers them.
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
---------
Co-authored-by: Codex <[email protected]>
Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>