Compare commits

..

432 Commits

Author SHA1 Message Date
Sir Clangington III. dc4d86ba40
fix(automation): sync staged output before publication
Sync staged output after writing all bytes and before replacing its destination, closing the handle before rename. Return sync failures while keeping the old destination and ordinary save state available for retry.

Fixes #1804
2026-10-13 21:44:45 -06:00
Ibn Zwanckaert 5e12f5fd6d
feat(ui): reorder and cycle document tabs
Reorder document tabs by dragging while keeping the active document and its view state. Add Ctrl+Tab and Ctrl+Shift+Tab cycling in visible order, including Combine files, and translate the menu labels.

Keep timestamped batch test expectations tied to the originally generated PDF bytes.

Closes #1537
Closes #1542

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:20 -06:00
Tanish Manku d9607ec3be F3: one documented substitution chain, and report what it chose and why
A font the document does not embed was replaced by whatever the caller
happened to pick, and nothing told the user. `doc_info` said only
`embedded: false`, which is why "my Chinese PDF shows nothing" has been
a mystery rather than a sentence.

`crates/fonts/src/fallback.rs` applies one chain, in a documented order,
and returns what it chose with a reason:

1. embedded -> nothing substituted;
2. /BaseFont names a standard-14 face or a known alias (exact metrics);
3. craft-fonts holds that exact family;
4. /CIDSystemInfo /Ordering names a script we have a face for;
5. the ordering names a script we have no licensed face for -- report
   none, so nothing is drawn rather than the wrong glyphs;
6. otherwise the nearest of the fourteen by descriptor flags.

It delegates to the existing per-script selectors instead of restating
their order, so each keeps one source of truth. It answers the
dictionary question only; "which face has a glyph for this character?"
depends on the string being drawn and stays where the drawing happens.

`doc_info` gains `substitute` and `substitution` per font. Both null
means the document's own glyphs are shown; a null `substitute` with a
reason means no face was available at all. That is Acrobat's Actual
Font, minus the interface, which `parity/` now records as still missing.

Two findings on the way:

`Std14::from_base_font` matches loosely, so "SomeHouseSerif" resolved to
Times by its name containing "serif". The face is a fair guess but
reporting it as "names one of the standard 14" would have been untrue of
a font nobody has heard of. `Std14::recognise` now says whether a name
matched an alias or only a family hint, and the two report differently.
`from_base_font` is unchanged.

The generated table moved to `crates/fonts/src/std14_metrics.rs`, data
alone, and the ATTRIBUTION entry and AGENTS.md 1.1 line follow it. F1
put the hash on a file holding both the table and the code reading it,
so this commit's ordinary edits tripped the gate -- and a hash that
churns on unrelated changes gets re-attested reflexively, which is the
protection gone. Verified both directions after the split: editing
std14.rs passes the gate, changing one width in std14_metrics.rs fails
it.

Every new test was watched failing first: removing the script step
breaks the two unit tests and the doc_info integration test.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-13 16:42:10 -07:00
jcl-bit 3f044b6263
fix(print): adopt native Windows spooler
Adopt the canonical craft-print revision from craft-libs #28 so Windows printing uses the in-process Windows.Data.Pdf/XPS backend instead of PowerShell. Keep exact paper, orientation and scaling validation and the existing PDFCraft composition and permission checks.

Pin the reviewed winprint fork, scope its compile-time licence/advisory exceptions, and distinguish the earlier WDAC Sandbox exercise from the final pinned revision's Windows CI coverage. Physical printer output remains unverified.

Add worker-barrier regressions for Home-selected tool handoff and startup document ordering.

Fixes #1107

Co-authored-by: durtyrobbin <76844303+durtyrobbin@users.noreply.github.com>
Co-authored-by: Dimitar Klaturov <7221384+bulgariamitko@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
2026-10-13 14:46:24 -06:00
skylar taylor-barrick b5467d93a0
fix(ui): preserve native open actions and password prompts
Carry selected tool actions through native file preparation, password retries and conversion, and defer startup intake until pending native opens finish.

Await observable completion in native opening, password, recovery and linked-PDF tests while retaining their content, identity, dirty-state, cancellation and destination checks. Verify the expected validation error and preserve the original document after refused consent targets.

M3; refs #1914.
2026-10-13 12:18:41 -06:00
bmh129 7437bde378
M3: draw a GNOME-style title bar on Wayland (#553) (#1530)
GNOME's Wayland compositor draws no window frames (it offers no
zxdg_decoration_manager_v1), so winit drew its own fallback title bar,
which ignores GNOME's theme, dark mode and button layout. PdfCraft now
asks the compositor at launch (apps/pdfcraft/src/decorations.rs) and,
where it draws no frames, turns winit's frame off and makes the tab strip
the header bar, as in GNOME's own apps
(crates/ui-egui/src/window_controls.rs):

- Minimize, maximize/restore and close in Adwaita's style (round buttons,
  symbolic glyphs drawn in code, no new assets), in the order and on the
  side named by org.gnome.desktop.wm.preferences button-layout, read
  through gsettings; Fedora's default `appmenu:close` shows only close.
  Hidden in full screen.
- Resize zones along the window's edges and L-shaped corners, above the
  rest of the UI so a press there resizes instead of reaching a
  scrollbar; a hairline outlines the window while it isn't maximized.
- Preferences > Use system title bar (shown only where the app draws the
  frame) gives the window back winit's frame at once, without a restart
  (part of #1001). Named as in VectorCraft and PhotoCraft, but GNOME draws
  no title bars for apps, so on GNOME this is winit's basic frame, not
  GNOME's own; the tooltip says so. Saved as `system_title_bar`;
  `system-title-bar` on the control channel.
- PDFCRAFT_TITLEBAR=app|system overrides the detection.

KDE, Sway, Hyprland, COSMIC and X11 offer server-side decorations and
keep the frame their desktop draws; macOS and Windows are unchanged.

Not done: right-clicking the header bar doesn't open GNOME's window menu
(egui has no command for it), the window has no shadow or rounded
corners, the resize zones lie just inside the window rather than outside
it, and Windows still uses its system frame (the rest of #1001).

The new labels (Minimize, Maximize, Restore, Use system title bar and its
tooltip) were added to all 21 translation catalogs. These translations
have not been checked by anyone fluent in those languages and should be
reviewed. The catalogs' hashes in ATTRIBUTION.toml are updated.

Made with Claude Code (Claude Opus 5.5, medium effort). It passes the
workspace quality gates (fmt, clippy, cargo test --workspace, xtask
assets) and was checked by hand on Fedora 44 GNOME (light and dark,
maximized and not, the preference toggled live), but it has not been
exhaustively regression tested.

Co-authored-by: bmh129 <11478682+bmh129@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 03:04:13 -04:00
Ridwan 7739685796
Issue #1396: remove an imported digital ID (#1515)
Digital IDs added through Sign with a Digital ID (imported .p12/.pfx files
or new self-signed ones) are saved with the app settings, but nothing ever
took one off the list, so an old or mistakenly imported certificate stayed
there for good (#1396).

Sign with a Digital ID now has Remove next to Configure New Digital ID. It
acts on the selected file-based ID, asks first ("Remove "<name>" from this
list? The file itself is not deleted."), and takes the entry off the saved
list. The .p12 file stays on disk. Keychain and Windows store IDs can't be
removed here: the OS manages them and they are listed again on every
signing.

- sign_ui: PdfCraftApp::remove_digital_id refuses OS store IDs and stale
  indexes, keeps the dialog's selection on the same ID (or the first
  usable one), and goes to Configure when no IDs are left.
- SignDraft::confirm_remove holds the entry awaiting confirmation; clicking
  another row cancels the question.
- i18n: the confirmation is translated in all 21 catalogs; ATTRIBUTION.toml
  hashes updated.
- Tests: a unit test for the list, the selection and persist/restore, and a
  real-shell test that imports a .p12, cancels, then removes it.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 02:49:38 -04:00
Mex e88dd728cb
M13: scan each directory once in CLI checks (#1501)
Co-authored-by: MMexicann <MMexicann@users.noreply.github.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 02:41:41 -04:00
Mex 5ebb53a4a4
M4: preserve shared Type 3 glyph resources during extraction (#1500)
Co-authored-by: MMexicann <MMexicann@users.noreply.github.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 02:34:12 -04:00
Mex 13a921ae4a
M2: keep byte boundaries in packed TIFF-predictor rows (#1499)
Co-authored-by: MMexicann <MMexicann@users.noreply.github.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 02:27:26 -04:00
srt8hellcat2-ui 4cb803038d
M3: confirm before opening absolute GoToR paths (#1364) (#1489)
* M3: confirm before opening absolute GoToR paths (#1364)

* fix(i18n): translate GoToR confirmation dialog labels

---------

Co-authored-by: srt8hellcat2-ui <225485253+srt8hellcat2-ui@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 02:16:21 -04:00
Marco Arnulfo 685fd7eeea
fix(text): read a TrueType font without an encoding as WinAnsi (#1477)
* fix(text): read a TrueType font without an encoding as WinAnsi (fixes #1468)

* fix(text): describe the WinAnsi reading by pdftotext's output

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 02:04:39 -04:00
Yousef Mojahid 0dd5fdd5bd
M7: keep Arabic ActualText ligatures in their word (#1473)
Normalize single-glyph RTL replacements before assembling editable lines. Preserve authoritative multi-glyph spans and cover lam-alef variants, marks, CID codes, replacement, copy/search and save/reopen.

Assisted-by: Codex

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 01:52:22 -04:00
Márk Bartalos fff985f30d
M5: keep stamps upright on rotated pages (#1398) (#1460)
A stamp placed on a page with /Rotate was drawn in user space, so the
label or picture turned with the page. Counterrotate the appearance so
it reads upright as the page is displayed.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 01:42:57 -04:00
Raj Patel dfa43beb9f
M10: BigTIFF and strict GIF detection in Create PDF; XDP data in format parity (#1453)
- Create PDF detects BigTIFF (II+/MM+) as well as classic TIFF; the tiff
  decoder already reads it, so these scans now convert instead of being
  refused.
- Image detection accepts only GIF87a/GIF89a, the headers conversion
  decodes, so a malformed GIF header is no longer called an image and then
  rejected with a misleading error.
- file-format-parity.md: XDP split into XFA data import/export (shipped
  under form.xfa-data) and opening .xdp as a document (none).

Tests: bigtiff_images_become_pages; malformed GIF headers in
source_kind_tells_pdfs_images_and_text_apart. Both fail without the fix.


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

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 01:19:48 -04:00
Márk Bartalos 9ed3abe490
Keep undrawn comments and accurate search hits (#1317, #1327, #1302, #1315, #1340) (#1442)
Flattening deleted a cloudy comment it could not draw, and a refused text-box restyle or resize left the new properties in the file. Arrow defaults were stored on plain lines. A custom match ending on a separator included the next glyph, and the vertical-text redaction error claimed an overlap it had not checked.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 01:09:36 -04:00
Earl Co 890b83c4c4
Add reviewable text-only PDF auto-tagging (#1440)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 01:02:05 -04:00
Marco Arnulfo 0372243ef2
fix(ui): the PDF/A dialog shows a declared PDF/UA (#1309) (#1438)
The declaration line was built from the PDF/A identification alone, so a
document that declares only PDF/UA-1 read "Declared conformance: none".
It now lists PDF/UA next to PDF/A, and still says none when neither is
declared. pdfa_dialog_shows_a_declared_pdfua fails before the change.
2026-10-13 00:49:39 -04:00
Marco Arnulfo 251917f685
fix(ui): the accessibility check keeps a saved page range inside the document (#1300) (#1437)
With the options dialog turned off, run_accessibility_check used the range
saved on an earlier, longer document as it was: a page past the end
selected no page, and the page rules came out Passed without looking at
anything. The range is now clamped to the active document's pages, as the
dialog already does. a_saved_page_range_past_the_document_still_checks_its_pages
fails before the change with Passed for Tagged content.
2026-10-13 00:47:16 -04:00
Filip Pranklin 4f375bf45c
fix(print): custom pages-per-sheet counts no longer overfill sheets (#1336) (#1430)
Layout::multiple(n) maps counts without a fixed grid to a near-square
grid (3 -> 2x2, 5 -> 3x2), but Layout::Multiple kept only the grid, so
layout() filled every cell: three per sheet put four pages on a sheet.

Layout::Multiple now carries per_sheet, capped by the grid; spare cells
stay blank and cut-stack piles use the same count. The doc_print tool
and the print dialog pass the count through.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:41:45 -04:00
Marco Arnulfo fc56888567
fix(text): keep doubled narrow letters in condensed text (fixes #1467) (#1474) 2026-10-13 00:39:07 -04:00
Marco Arnulfo 7eac105c00
fix(ui): Combine flags the same converted file added twice (#1303) (#1458)
Follow-up to #1420.

#1420 compares the bytes of each entry to tell a file added twice from a
different file with the same name and size. An image or text file is
converted to PDF each time it is added, with its own date, so those bytes
never match and the same .txt or image added twice was no longer flagged.

Each entry now keeps the file as it was added (the same bytes for a PDF,
the image or text otherwise), and that is what is compared.

Adds the_same_image_or_text_file_added_twice_is_a_duplicate in
combine_ui.rs, which fails before this change.
2026-10-13 00:36:44 -04:00
magdale76 929ae1155b
Windows: open a path a launcher passed unquoted and split at its spaces (#1456)
Some programs start the PDF reader with the path unquoted
(`pdfcraft.exe P:\Archive\00042A26 Contract.PDF`), so Windows splits it
into `P:\Archive\00042A26` and `Contract.PDF` and neither opens
("Couldn't read 00042A26: os error 2"). Acrobat accepts this. Rejoin
consecutive arguments when one names no file but it and the ones after it,
joined with a space, do (longest run first); everything else is kept as
given. Done before the single-instance hand-off, so files sent to a running
PdfCraft are rejoined too.

Co-authored-by: magdale76 <16279778+magdale76@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:36:35 -04:00
Marco Arnulfo 595026aa69
fix(ui): ui.click and ui.drag refuse coordinates that overflow (#1306) (#1447)
ui.move refuses a coordinate that isn't finite once narrowed to a
pointer position; ui.click {x, y} and ui.drag did not, so 1e39 became
an infinite position and was queued. With debug assertions on, the
next idle frame panicked in emath comparing NaN vectors.

Both now refuse such a coordinate with an error, as ui.move does, and
ui.drag also refuses ends too far apart for the way between them to
stay finite.

Adds click_and_drag_refuse_coordinates_that_overflow_like_move in
tests/control.rs, which panics before this change.
2026-10-13 00:36:25 -04:00
Marco Arnulfo cced834190
fix(ui): a rule skipped by hand stays with its document (#1301) (#1445)
Rules skipped in the accessibility results were kept in one set for
the whole app, and every check applied it. With the options dialog
turned off, skipping Tagged PDF in one document made the next
document's check report it as Skipped, its findings cleared, in the
panel and the saved report.

The set is now keyed by document: Skip Rule records the rule for the
document whose report is shown, and a check applies only that
document's skips. Start Checking in the options dialog still starts
over, for the document it checks only.

Adds a_rule_skipped_in_one_document_is_not_skipped_in_another in
tests/accessibility.rs, which fails before this change.
2026-10-13 00:36:15 -04:00
Sir Clangington III. 082768ac29
M13: Bound cached page text and keep partial search results (#1492)
Use a session-wide byte-accounted LRU instead of retaining every visited
page. Share the renderer's PageText Arc without cloning all glyphs and
avoid retaining obsolete document bytes through the cache.

Preserve per-page extraction results so text_find can continue after a
failed page, reporting bounded warnings alongside readable matches and
normal continuation cursors. Other text consumers retain strict errors.

Add deterministic coverage for cache budgets across documents, eviction,
revision changes, close, partial searches and bounded warnings.

Fixes #1483.

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
2026-10-13 00:19:59 -04:00
Alex 9324afaa4f
fix(organize): Document Properties also change the XMP title and author (#1658)
`set_info` changed only `/Info`. Files that carry XMP (LibreOffice and Word
exports, PDF/A) kept the old dc:title and dc:creator, so viewers that read
XMP first kept showing the old title, and PDF/A preflight reported the two
as different (6.6.3).

The matching XMP property (Title dc:title, Author dc:creator, Subject
dc:description, Keywords pdf:Keywords, Creator xmp:CreatorTool, Producer
pdf:Producer) is now replaced where it stands, added in a description of
its own, or removed with an empty value. The packet is edited as text and
the rest of it stays byte for byte; files without XMP get none, and a
packet that isn't UTF-8 is left as it was.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:18:35 -04:00
Marco Arnulfo e8fcf76c33
Render: a soft mask shrunk below its size is filtered, so thin strokes drawn through it survive (fixes #1649) (#1654) 2026-10-13 00:18:27 -04:00
SteamSmith f9d7aa72b6
control: cap line length before auth (#1653)
Follow-up to #1259. Before a connection authenticates, the control server
read request lines with BufRead::lines, which buffers a whole line before
anything looks at it, so a client that never sends `auth` could make it
buffer an arbitrarily long line.

Read lines with a bounded read instead. Until `auth` succeeds a line may be
at most MAX_AUTH_LINE (4 KiB; the auth request is about a hundred bytes);
a longer one gets a -32600 error and the connection is closed, after
reading at most MAX_AUTH_LINE + 1 bytes of it. After `auth`, lines are read
whole as before. End of stream, read errors and non-UTF-8 input end the
connection as they did with BufRead::lines.

Tests: lines_are_read_up_to_the_limit, an_overlong_line_before_auth_is_refused,
long_lines_after_auth_are_still_read.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:18:19 -04:00
Marco Arnulfo 2f09340af1
CLI: text refuses a document that doesn't allow copying (#1650)
* CLI: text refuses a document that doesn't allow copying (part of #1645)

* CLI: text's refusal points to --password
2026-10-13 00:16:54 -04:00
Marco Arnulfo a0bb1f08a4
Export all images: CMYK JPEGs keep their colours instead of coming out as negatives (fixes #1640) (#1642) 2026-10-13 00:16:45 -04:00
Marco Arnulfo a6aa05cd7b
XFA: place positioned objects by their anchorType (fixes #1637) (#1639) 2026-10-13 00:16:34 -04:00
FredApps 03d9900990
M3: preserve IME composition in command search (#1252) (#1638)
Co-authored-by: Codex <noreply@openai.com>
2026-10-13 00:16:26 -04:00
Marco Arnulfo da9cefa33c
M4: copied pages keep the resources they list only up the page tree (part of #1628) (#1635) 2026-10-13 00:16:19 -04:00
Marco Arnulfo 6a948fcc22
M4: Add open documents leaves out the documents already in the Combine list (part of #1356) (#1632) 2026-10-13 00:14:47 -04:00
Jeremy Yao 759cfa65f4
Security: fix three audit findings in redaction and signature validation (#1631)
* M8: redaction unlinks removed images and forms from inherited and shared resources

Apply Redactions and Remove Hidden Information give a rewritten page its own
pruned /Resources, but the dictionary the page drew through before could still
name the image or form that was removed:

- a /Resources inherited from a /Pages node;
- one resource dictionary object shared by several pages;
- the page's own dictionary, when a form without /Resources drew the image
  through it.

A full-rewrite save keeps whatever is still referenced, so the removed image
samples and form text stayed in the saved file while the operation reported
success. Per-page resources (the case fixed in #1099) were not affected.

The interpreter now records the original object behind every XObject it drops
or replaces. After the page loop, each page-tree resource dictionary that still
names one loses that entry unless a page drawing through that dictionary still
draws the name (the same image on another page, or again outside a mark). A
dictionary that loses an entry is replaced by a private copy on its node, so a
dictionary object shared with other nodes is left alone for them. If a page's
use of a name cannot be told (unreadable content, a form that will not decode),
the operation fails closed.

Tests: redacted_xobjects_do_not_survive_through_inherited_or_shared_resources,
an_image_another_page_still_draws_through_the_same_resources_is_kept,
an_image_a_form_drew_through_the_pages_resources_does_not_survive,
a_hidden_layers_image_does_not_survive_through_inherited_resources.

Not covered here: originals named by a form XObject's own /Resources, by an
annotation appearance or by a pattern.

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

* M9: a cross-reference entry the reader sets aside is never an unchanged signed object

The reader that judges post-signing changes and the renderer that draws the
page resolve some cross-reference entries differently. A revision appended
after signing could redefine a page's content stream and give the object an
entry the reader sets aside while the renderer follows it:

- an in-use entry at offset 0 (the reader skips it; the renderer fails to read
  the object there, rebuilds its index and takes the last definition);
- a second entry for the same object in one section (the reader keeps the
  first, the renderer the last).

The classifier compared the signed object with itself, found nothing changed
and reported the signature Valid while the page showed the new content.

pdfcraft-cos now records the entries it does not take at face value
(Document::xref_oddities: offset-0 in-use entries other than object 0,
duplicate entries that differ, reserved cross-reference stream types, and a
hybrid /XRefStm entry that disagrees with the table), and can say whether an
entry locates the object itself (Document::xref_entry_is_exact). What it
resolves is unchanged.

classify_changes treats any such entry added after the signed revision as a
change that is never permitted (page content when the object was a signed page
or its content, other changes otherwise). An object whose content compares
equal at a new place is unchanged only when its entry is exact: where the
reader had to search for the object, another reader may arrive elsewhere.

Tests: a_cross_reference_entry_the_reader_sets_aside_does_not_hide_new_page_content,
a_cross_reference_entry_that_misses_its_object_is_not_an_unchanged_object,
cross_reference_entries_that_are_set_aside_are_recorded,
an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact.

This closes the two demonstrated triggers and their near variants. It does not
prove that the two parsers agree on every file: other differences in what each
tolerates may remain.

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

* M8: Apply Redactions is refused when the page still shows text under a mark

Redaction decides what lies under a mark from the positions its own
interpreter computes, and its verify pass reuses them. Where those differ from
the renderer's, a mark drawn over the text as displayed lies over glyphs the
interpreter places elsewhere, or does not see at all: they stayed in the file
and the operation reported success.

The case the test pins: a font and size set through an ExtGState /Font entry
(`gs`), which the renderer honours and the interpreter does not model. The same
kind of gap was observed for a font a form XObject takes from its caller, for
some predefined CMaps and for a rotated Type 3 font matrix. The standard-14
width error that first showed it is fixed at its root by d0d1ad81.

The engine now asks the renderer, which is what the user saw when placing the
marks. After redaction runs, the result is written as a save would write it
(without the marks' own overlay), its text is extracted, and a glyph still at
least half inside a mark fails the edit with the existing "could not be
verified" error. The edit runs on a copy, so the document is unchanged. A page
whose text cannot be read back fails the same way.

This makes such mismatches fail closed; it does not make those documents
redactable. Teaching the interpreter each of them is separate work.

Test: redaction_is_refused_when_the_page_still_shows_text_under_a_mark (the
line whose font is set by `gs` is refused and left unchanged; the same line set
with `Tf` is redacted).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:14:40 -04:00
FredApps 396c9c7836
UI #1114: sanitize suggested attachment save filenames (#1630)
Refs #1114, item 3 only. Preserve stored metadata, payload bytes, and the user-selected destination.

Co-authored-by: OpenAI Codex <noreply@openai.com>
2026-10-13 00:14:32 -04:00
Fabian Grajales e072bb8317
macOS: start on wgpu on Intel Metal by skipping indirect-call validation (#1627)
wgpu validates indirect draw and dispatch arguments with a compute shader it
compiles when the device is created. Metal on an Intel Iris Pro (MacBookPro11,3,
macOS 12.7.6) fails to compile it ("Compiler encountered an internal error" in
MTLCompilerService), the device is lost, and the window falls back to OpenGL.

PdfCraft draws and dispatches nothing indirectly, so leave the check out of the
instance flags. WGPU_VALIDATION_INDIRECT_CALL=1 turns it back on. VectorCraft
does the same (storytold/vectorcraft#651).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:14:23 -04:00
Marco Arnulfo c0c40acf22
Save: fall back to a plain fsync where a volume refuses the full flush (fixes #981) (#1626)
On macOS sync_all is F_FULLFSYNC, which SMB and FUSE volumes such as a mounted Cryptomator vault refuse with ENOTSUP or ENOTTY, so every save there failed. Those two errors now fall back to fsync; any other flush error still fails the save.
2026-10-13 00:14:15 -04:00
Marco Arnulfo c4bf96b897
Telugu interface: draw with an installed Telugu face when the build has none (part of #1531) (#1611) 2026-10-13 00:12:37 -04:00
Sir Clangington III. 7a0f846a28
M13: Run conversion, OCR and optimization over folder trees (#1605)
Add a shared CLI/MCP batch runner with bounded discovery, retained relative
paths and per-file processing in isolated engine sessions. Publish complete
outputs without overwriting existing names, and return structured errors
with an optional stop-on-error policy.

Cover recursive jobs, overlapping inputs, concurrent output collisions,
root confinement and partial-failure reporting in automation and CLI tests.

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
2026-10-13 00:12:28 -04:00
Marco Arnulfo 73efaef13e
fix(ui): keep a closed right panel closed when opening a document (fixes #1590) (#1603) 2026-10-13 00:12:16 -04:00
Marco Arnulfo e125a2d659
fix(edit): move existing text by its frame, and selections with the arrow keys (#1600)
* fix(edit): move existing text by its frame, and selections with the arrow keys (fixes #1520)

* docs(parity): arrow-key nudging and the paragraph frame are no longer gaps (#1520)
2026-10-13 00:12:07 -04:00
Umberto Cimmino 8b365afafe
Fix single-click freehand drawing (#1597) 2026-10-13 00:12:00 -04:00
FredApps 0b417441f6
Forms #294: detect rectangles drawn with closed line paths (#1596)
Refs #294. Preserve rectangle geometry without inventing rule fields from its edges.

Co-authored-by: OpenAI Codex <noreply@openai.com>
2026-10-13 00:10:30 -04:00
ysrdevs 29f4edf5be
XFA: a script's time starts when its context is ready, and a value reaches every field bound to its data node (#1589)
Two follow-ups the maintainer asked for after #1355.

The script session's worker now says when the engine is ready for the
script it was sent (its JavaScript context built; FormCalc at once),
and the script's time limit runs from then, with the engine's own
start limit before it, as the one-shot path always did: building a
context is not charged to the script, which matters on a busy machine.

A field inside an area shares its data node with a field of the same
name outside it (areas are left out of data paths). The in-place
refresh of the live form now follows the data path through the tree
and gives every field bound to the node the value the tree builder
would, each by its own template field (an exclusion group there means
a rebuild), and the engine's value setter, with an index of widgets by
data node, gives every widget bound to the node the value by its own
kind, hidden fields included, writing the data whenever the node's
text differs. The script's time now also starts after its host and
globals are set up. A stale parity note (choosing a picture for an
image field, delivered in #1175) is removed.
2026-10-13 00:10:22 -04:00
MarekSpodBiedry 2ce4fb279f
Fill & Sign: don't show the comment hover popup over placed text (#1504) (#1576) 2026-10-13 00:10:14 -04:00
MarekSpodBiedry 9215b6f3b4
Name the failing option and page count when a chained edit page is out of range (#1550) (#1575) 2026-10-13 00:10:05 -04:00
pauliusiv123-rgb 91c0b93c7f
Issue #1058: an update keeps the desktop shortcut only if it is still there (#1570)
The launcher installs and updates PdfCraft with `msiexec /passive` and no properties, so
INSTALLDESKTOPSHORTCUT always took its default of 1 and every update put the shortcut back,
even for users who had deleted it. The property no longer has a static default: a SetProperty
after AppSearch turns it on for a first install, and for an update only while PdfCraft.lnk is
still on the all-users desktop. A value on the command line still wins, and the full-UI
checkbox still starts ticked on a first install.

test-msi.ps1 checks the action runs after AppSearch in both sequences and evaluates its
condition for first installs, updates with and without the shortcut, and
INSTALLDESKTOPSHORTCUT=0.
2026-10-13 00:09:56 -04:00
Jason Brashear e9b9d98bb4
fix(xfdf): preserve quoted empty CSV values (#1541) 2026-10-13 00:07:40 -04:00
bmh129 13fbb2f6fb
M0: label local builds in --version, About and the package release (#1529)
A build compiled with PDFCRAFT_BUILD_LABEL=<label> shows "0.7.1 (<label>)"
in `pdfcraft --version`, `pdfcraft-cli --version`, Help > About and the
first log line (pdfcraft_engine::build_info). The version itself is
unchanged, so the update check, the MCP server info and the User-Agent
still carry the release version.

packaging/linux/package.sh puts the label in the file names
(pdfcraft-0.7.1-<label>-linux-x86_64.rpm) and makes the .rpm/.deb release
`1.<label>` (nfpm `release: ${RELEASE}`, 1 when unlabelled). rpm sorts
0.7.1-1.<label> after 0.7.1-1 and before 0.7.2-1, so a locally built
package upgrades over the installed release and the next release replaces
it. packaging/env.sh accepts only letters, digits and inner dots, which
are valid in both rpm and deb release fields.

Unlabelled builds are unchanged.

Made with Claude Code (Claude Opus 5.5, medium effort). It passes the
workspace quality gates (fmt, clippy, cargo test --workspace, xtask assets)
and was checked by hand on Fedora 44, but it has not been exhaustively
regression tested.

Co-authored-by: bmh129 <11478682+bmh129@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:07:32 -04:00
Mahmud Emre BAŞPEHLİVAN a4126d34e7
docs: merge the duplicated Auto paragraph in localization.md (#1525)
Two language additions each edited the "Auto follows the system
language" paragraph, and both versions ended up in the file: one
mentioned Turkish, the other Slovenian, and neither mentioned both.
Keep one paragraph that lists both.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:07:22 -04:00
Marco Arnulfo 80eff6775f
fix(audit): a rectangle drawn as an m/l path is a cover like re (#1521)
* fix(audit): a rectangle drawn as an m/l path is a cover like re (fixes #1510)

* docs(parity): a rectangle drawn as an m/l path counts as a cover (#1510)
2026-10-13 00:07:15 -04:00
FredApps 6803ee894b
Allow mouse-wheel scrolling during text selection (#1514)
* Allow mouse-wheel scrolling during text selection

* Keep wheel-scrolling a selection drag over the page gap and the next page (#721)

Move the wheel handling out of the per-page drag branch to after the page
loop, gated on the pointer being over the document area (like #759), so it
no longer stops once the pointer leaves the selection's page.

Co-authored-by: Waleed <waleedalw123@gmail.com>

---------

Co-authored-by: Waleed <waleedalw123@gmail.com>
2026-10-13 00:05:58 -04:00
Jason Brashear a9745dd22d
fix(organize): normalize rotations before adding so large quarter turns cannot overflow (#1512)
* Contributors: credit webdevtodayjason as Jason Brashear

* fix(organize): normalize rotations before adding so large quarter turns cannot overflow
2026-10-13 00:05:50 -04:00
Marco Arnulfo 801a2f1488
fix(ui): dragged added text follows the pointer before release (#1508)
* fix(ui): dragged added text follows the pointer before release (fixes #1488)

* fix(ui): keep a moved item in place until the page is redrawn (fixes #1488)
2026-10-13 00:05:43 -04:00
cyfung1031 55f76ab767
Desktop: prepare large PDFs off the UI thread (#1493)
* #307: split document open into prepare and commit

* #307: move desktop open preparation off the frame thread
2026-10-13 00:04:23 -04:00
Earl Co ecc4a305d7
M12: move tags in the order panel (#1482) 2026-10-13 00:04:16 -04:00
FredApps 6ed78b9133
fix: allow links beneath highlight annotations (#1387) (#1481)
AI-assisted with OpenAI.

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-10-13 00:04:05 -04:00
Marco Arnulfo 68f8b2d5f7
fix(edit): flattening form fields also drops the ones whose widgets are on no page (fixes #1470) (#1480) 2026-10-13 00:03:55 -04:00
Marco Arnulfo 0d300c6a41
fix(render): Type 3 text ignores rendering modes other than invisible (vendored hayro-interpret patch 27) (fixes #1469) (#1478) 2026-10-13 00:03:47 -04:00
Márk Bartalos c6128c039a
M7: keep a neighbouring line when replacing text (#1316) (#1427)
Glyph boxes of tightly spaced lines overlap, so replacement treated the next line as a duplicate and removed it. A second drawing is removed only when it also shares the edited line's baseline.
2026-10-13 00:01:50 -04:00
FredApps fcc1d9ead0
M2: adapt Lab Bradford white points in LMS (#1417)
Follow up #1237 by deriving Bradford scaling ratios from the source and
D65 white points in LMS. Preserve CalRGB and the merged Indexed/Range
handling. Add independent neutral/chromatic, identity, asymmetric Range,
and degenerate-cone render regressions.

Co-authored-by: Codex <noreply@openai.com>
2026-10-12 23:59:51 -04:00
Jules Kreuer 057e453a16
Retire inherited resources (#1348)
* M8: Retire inherited redaction resources

* Redaction: pin inherited resources only on redacted pages, shared by reference

localize_resources rewrote every page in the document on every apply and copied a direct
inherited /Resources dictionary into each one, so a large document grew by N x the dict.
Now only redacted pages that inherit resources are pinned; a direct inherited dictionary is
made one indirect object and shared by reference. The parent's /Resources is retired when no
other page still inherits it (the leak fix), and kept (through the shared reference) when an
unredacted page still draws with it. A /Parent chain that can't be followed or disagrees with
the page tree retires nothing. Tests: an unredacted page is left untouched and the rewrite is
bounded; redacting every inheriting page retires the parent's resources. ROADMAP entry moved
into the revision-history table.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:49:57 -04:00
Tanish Manku 0a09864833 F1, F2: adapt to main and re-pin the Arabic layout golden
Rebase onto 7163ba3 (396 commits on). Three adaptations, none of them
behavioural:

- `crates/fonts` renamed its `arabic` module to `shaping` and added
  Thai, so `Family::width`'s callers moved; the italic argument is
  re-applied to all six, including one new call site main added in
  `draw_laid_shaped`.
- `Metrics::text_of` now returns `Cow<str>`, so the F2 tests compare
  through `as_deref()`.
- `unicode_map` writes into a struct field now, so `/Differences`
  inserts go to `out.encoding`.

`SMALL_ITEM_FINGERPRINT` in added/arabic_tests.rs is re-pinned, and this
is the part worth reviewing. That golden is the appearance-stream pin
F1's acceptance criteria asked for, which landed from the Arabic work
after F1 was written; it caught the change correctly. The item's Latin
run is "Hi " in Helvetica 12pt. The old character-class guess called H
680, i 260 and space 260 (14.4pt); the published metrics are 722, 222
and 278 (14.664pt), verified against the table. The Arabic clusters that
follow start 0.264pt further right, which is the intended effect: the
glyphs now land where the font says they do. The Arabic path itself is
untouched.

Also dropped from the rebase: a re-added ATTRIBUTION entry for
vendor/hayro-interpret/assets/LAB.icc, which main deleted in favour of
CGATS001Compat-v2-micro.icc. Carrying it would have attributed a file
that no longer exists.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-12 13:57:43 -07:00
Tanish Manku ce9a1d1014 F2: read glyph names by the naming algorithm, and stop reading Symbol as Latin
Two gaps in code-to-Unicode, both found by probing the current
behaviour rather than by reading the code.

A font with no /Widths and no /ToUnicode fell back to StandardEncoding
whatever it was, so /Symbol code 0x61 -- `alpha` in Symbol's own
built-in encoding -- decoded as "a". ZapfDingbats and MacExpert were
read as Latin the same way. That map is what `redact` matches patterns
against, so a wrong character there is a silent redaction failure, and
a wrong character is worse than no character. Symbol, ZapfDingbats and
MacExpert now yield no mapping at all unless the font names a base
encoding, which is always honoured as the deliberate statement it is.

This keys on the base font *name*, not the /FontDescriptor symbolic
flag. A great many ordinary Latin TrueType subsets set that flag and
still rely on a Latin encoding, so suppressing on the flag would lose
real text across a large share of real documents. A test pins that
restraint so it is not "fixed" into a regression later.

Glyph names now follow the published naming algorithm instead of two
of its cases: a name is cut at its first `.` (`a.sc` is `a`), split on
`_` into components (`f_i` is "fi"), and `uniXXXX` carries any number
of four-digit values (`uni00660069` is "fi"). Names are resolved whole
or not at all, so half a ligature is never returned. `glyph_text`
returns a String for this; `glyph_unicode` stays as the single-character
view. Hex parsing rejects the leading `+` that `from_str_radix` would
otherwise accept, and a lone surrogate half.

Both halves were verified by reverting each fix and watching the
matching tests fail. The parity note on protect.redact-patterns now
says Symbol-encoded text is skipped rather than mis-decoded.

Still open, and an owner decision: Symbol and ZapfDingbats could be
mapped properly with a glyph-name-to-Unicode table, but the vendored
Adobe Glyph List is not on the AGENTS.md 1.1 closed list.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-12 13:57:43 -07:00
Tanish Manku 117430fade F1: measure an added item with the face it is written as
`Family::width` chose its standard-14 face from `bold` alone and passed
`italic: false`, while `Family::base_font` writes the item out as the
italic or oblique face. Every italic item was therefore laid out with
upright metrics: Times-Italic `A` is 611 against Times-Roman's 722, so
boxes, wrapping and centring were all sized from the wrong face.

The measure now takes `italic` and the five call sites pass the item's
own flag, which they already had in scope. The test walks all twelve
style combinations and asserts the written face and the measured face
are the same one, so the two can't drift apart again.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-12 13:57:43 -07:00
Tanish Manku aaa19d87af F1: put the generated table on the AGENTS.md 1.1 closed list
Isolated so it can be reverted on its own.

AGENTS.md 1.1 keeps a closed list of Adobe-authored non-visual technical data,
and adding to it needs the owner's explicit approval, which was given on
2026-10-08. The item itself -- the standard-14 font metrics and encoding tables
from the PDF specification -- was already on that list; what is new is holding
a copy first-party rather than only through hayro, which is what lets generated
appearances measure with it and what survives hayro being retired in M2.8.

The gate only understood the list for dependency-bundled files, and rejected
adobe_data on a repository file outright. It now accepts it for a closed list
of first-party paths, so crates/fonts/src/std14.rs carries an ATTRIBUTION entry
with its SHA-256: regenerating the table fails the gate until the entry is
updated, which makes a change to Adobe-derived data a deliberate act rather
than an ordinary edit.

Worth noting that .rs is not an asset extension, so the table would never have
been scanned at all. Entering it on the list is a choice to be checked, not a
requirement the gate imposed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 13:57:43 -07:00
Tanish Manku d0d1ad8192 F1: exact standard-14 metrics
pdfcraft_fonts::helvetica_width was a fifteen-line guess by character class,
and seven crates generated appearance streams through it: annot, edit, forms,
ocr, redact, sign and xfa. Times was Helvetica x 0.9, bold was x 1.05, Courier
a flat 0.6. Against the published metrics a Times capital A is 722, so that
estimate said 600 -- a 17% error on a letter, compounding across a line.

Std14 carries the real advance widths for all fourteen faces, indexed by
character code: WinAnsiEncoding for the twelve text fonts, which is what we
write into the appearance streams we generate, and their own built-in
encodings for Symbol and ZapfDingbats. The tables are generated by joining the
published encodings with the per-glyph widths.

helvetica_width now delegates, so every call site gained exact Helvetica with
no change. The three places that faked another face by scaling it -- Family
in edit::added, the paragraph measure in edit::text, and the redaction overlay
fonts -- now select the right face instead. So does Metrics::width in
fonts::pdf, which measures existing document text when a font carries no
/Widths; that path feeds redaction's glyph removal, where a wrong width means
cutting the wrong glyphs.

Tests spot-check the table against values typed in from the specification by
hand, so it is verified against the spec rather than against whatever produced
it; plus Courier being fixed pitch everywhere, full printable-ASCII and Latin-1
coverage, Symbol and ZapfDingbats not being WinAnsi, /BaseFont aliases and
subset prefixes resolving, and hostile text staying finite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 13:57:43 -07:00
Tanish Manku 681548a47f Add a PR template, and record the review practice and external-PR bar
Replaces this PR's earlier contents. It previously added a committed
`docs/plan/` tree -- a status baseline, an architecture doc and an
execution plan. Since then `main` has grown `docs/gaps.md`,
`docs/target-app-parity.md`, `docs/roadmap.md` and `docs/architecture.md`,
which are actively maintained (gaps.md was reviewed 2026-10-10 and
updated 2026-10-11) and cover the same ground better, because they sit in
the flow contributors already touch. Keeping a parallel plan would have
meant two sources of truth and a document that rots; it had already gone
conflicting twice in three days. So the plan is dropped and only the
parts with no equivalent anywhere remain.

`.github/pull_request_template.md` is new -- the repository had none,
with 167 PRs open and 66 of them conflicting. It leads with the two
things a contributor cannot be expected to know and that cannot be undone
after a merge: provenance (no Adobe asset, no committed font, no copied
GPL/AGPL code, behaviour from the spec rather than from reading another
implementation) and the never-crash rules. It also asks for a test that
has been *seen to fail* before the fix, and says plainly that per-crate
verification is preferred because CI runs the workspace anyway.

CLAUDE.md gains three things it did not record: the four-zoom review
habit (clause, function, file, project) with the tests that mirror it;
the external-PR bar; and a note that builds and tests should be scoped
per crate, because a full workspace build has locked up a development
machine and CI covers it. The commit rule now also says that `main` moves
fast enough for PRs to rot -- 396 commits in three days -- and that
conflict risk tracks how contested the files are rather than how large
the change is.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-12 13:56:58 -07:00
Brandon Thomas 7163ba3dee Release 0.7.1: rebuild with the fixed shared release workflow
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:11:04 -04:00
Brandon Thomas 07df4de63d
Release workflow: pass the Azure signing secrets so Windows builds are signed (#1194)
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:08:29 -04:00
Márk Bartalos 87b448b664
M1: write a valid xref when an incremental save has no objects (#1339) (#1424)
An unchanged classic-table file used to append xref immediately followed by trailer, which qpdf treats as a damaged file. A zero-length subsection keeps trailer-only updates without rewriting any object entry.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 08:57:37 -04:00
tdslot 4bfd3c2729
M14: add Lithuanian interface and date localization (#1401)
* M14: add Lithuanian interface localization

Register Lithuanian UI and date languages with three integer plural forms
and a 2,572-entry contributor-original catalog. Cover dynamic dialog labels,
contextual calendar and certificate wording, commands, tools, and every key
in the other bundled catalogs. Add real-shell calendar, inspector and
password-dialog regressions; preserve PDF values and document state.

Update localization documentation and asset attribution. Native-speaker/VLKK
terminology review remains outstanding.

Validation: cargo xtask ci passed; cargo-deny was skipped because it is not
installed. Five rendered dialogs and contributor manual testing checked UI.

Co-Authored-By: OpenAI gpt-6.1-sol <noreply@openai.com>

* M14: preserve multilingual localization documentation

Restore the existing language descriptions instead of replacing general
localization guidance with Lithuanian-specific coverage. List every
registered language and control-channel code; keep Lithuanian details
alongside the existing catalog descriptions.

Include the previously omitted Kazakh, Polish, Slovenian, Greek and Turkish
catalogs in the shipped-language table without changing the historical
coverage snapshot or existing assessments. Keep Lithuanian roadmap notes
concise and additive.

Validation: all 22 registered languages appear in documentation; every
existing descriptive paragraph is retained. cargo xtask ci passed, with
cargo-deny skipped because it is not installed.

Co-Authored-By: OpenAI gpt-6.1-sol <noreply@openai.com>

* M14: record completed Lithuanian native-speaker review

The contributor confirms they are a native Lithuanian speaker and have
reviewed the translation. Remove obsolete pending-review labels from the
asset manifest and documentation; regenerate ATTRIBUTION.md. Remove the
redundant generated-message disclaimer from the Lithuanian description,
keeping the existing general localization documentation unchanged.

Validation: cargo xtask ci passed; cargo-deny was skipped because it is
not installed. Catalog content and its SHA-256 are unchanged.

Co-Authored-By: OpenAI gpt-6.1-sol <noreply@openai.com>

* Lithuanian: cover main's Copy Image labels, list lt last, check its glyphs

Adds the six Copy Image rows from #1363, moves lt to the end of LANGUAGES
and DATE_LANGUAGES (after tr), adds lt.tsv to the primary-font coverage
test, writes the JavaScript preferences path with '>' as the other
catalogs do (Inter has no ▸), and refreshes counts and hashes.

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

---------

Co-authored-by: OpenAI gpt-6.1-sol <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:54:15 -04:00
Baltsar a6d48707f7
feat(preflight): verify image resolution and bleed without rewriting the file (#1408)
* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* feat(preflight): report image resolution and bleed without rewriting the file

* test upload session tests only

* Include the print-readiness module from the OCR module so the crate compiles.

* Record the one-line print-readiness patch so the dispatch can be restored exactly.

* Restore the print-readiness dispatch from the original sources.

* M3: select an image on the page and copy it (#1363)

* M3: select an image on the page and copy it

Select tool: a click on a raster image selects it (also one drawn through Form artwork, or with
text over it such as an OCR layer); ⌘C copies it as a file in its original format (a JPEG as
stored in the PDF, otherwise a lossless PNG). Right-click ▸ Copy Image copies its pixels at full
resolution, and Copy Image in Original Format the file; Edit text & images has both for the
selected image. Both respect the document's copy permission.

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

* M3: translate the copy-image strings into Czech, Kazakh, Slovene and Turkish

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

* Copy image: keep the clipboard file in a private per-user folder

Copy Image in Original Format wrote into the shared temporary folder
(/tmp/pdfcraft-clipboard) and deleted everything in it first, so another
user on the machine could plant a link or folder there and have PdfCraft
delete or overwrite their target. The file now goes in PdfCraft's
per-user cache folder (or the portable data folder), which is checked to
be a real folder and set to 0700 on Unix (refused when it isn't ours).
Only earlier copies (regular files named '<stem> p<N> img<M>.jpg|png')
are removed, links are never followed, and the new file is created with
create_new so it can't be redirected.

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

* Tests: escape Windows paths in synthetic GoToR file specifications

canonicalize() returns \\?\C:\... on Windows; unescaped backslashes in a PDF literal string become control characters, so link_open refused the target and main's Windows CI failed. Taken from #1359's helper fix.

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

---------

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

* fix(cli): edit refuses move/insert destinations past the end (#1330) (#1400)

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

* fix(sign): refuse to sign a field whose /V is not a signature (#1328) (#1402)

Follow-up to #1374. Signing an existing field refused a field whose /V
is a signature dictionary and signed one whose /V is missing or null,
but a /V that is neither (an empty string, a number, a name, an array)
was treated as unsigned and overwritten with the new signature.

Signing such a field by name now fails with "the value of the field
... is not a signature" and leaves the document as it is. A missing or
null /V, direct or indirect, still signs, and a signed field is still
refused as already signed. The signature list is unchanged: it still
shows such a field as unsigned, since it carries no signature.

Adds a_field_whose_value_is_not_a_signature_is_refused_by_name, which
fails before this change.

* fix(forms): keep a whitespace-only value in a text field without a format (#1323) (#1403)

The commit-time Keystroke check returned an empty value for any
whitespace-only text before looking at the field's format, so setting a
space, a tab or a non-breaking space in a plain text field reported
success and removed the field's value. Text and XFDF imports go through
the same setter and lost the value the same way.

Blank input now clears only a field with a format (number, percent,
date, time, special or mask), as before; a field without one stores the
text as given. An empty value still clears any field.

Adds tests::whitespace_only_text_is_kept_in_a_field_without_a_format
and a keystroke assertion in af::tests::numbers_format_like_acrobat,
both of which fail before this change.

* fix(content): skip an inline image with an unreadable key to its EI (#1318) (#1404)

When a key in an inline image dictionary was not a name (`BI /W 4 H 4
ID ... EI`), the parser ended the image at the end of the stream and
counted nothing as skipped, so every operator after the image was lost:
text_lines found no text on a page whose text follows such an image.

An image whose parameters can't be read is now skipped up to the EI
that ends it, the skipped bytes are counted, and parsing resumes after
it. Without an EI the image still runs to the end of the stream, as
before.

Adds tests::an_unreadable_inline_image_is_skipped_to_its_end, which
fails before this change.

* fix(automation): doc_save reports a required full rewrite as not incremental (#1329) (#1405)

doc_save reported "incremental": !full, from the arguments alone. When
an in-place save had to be a full rewrite (after doc_protect adds or
removes encryption, or after Sanitize), write_incremental fell back to
write_full, but the result still said the save was incremental.

The result now says whether the written file starts with the file it
was saved over, read from the new Document::saved_bytes. An ordinary
in-place save still reports true and an explicit full save false.

Adds an_in_place_save_reports_a_required_rewrite_as_not_incremental,
which fails before this change for both doc_protect and
doc_remove_hidden.

* fix(audit): an identity cm between re and fill no longer hides a cover (#1333) (#1410)

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

* fix(ui): Current page prints the viewed page, not a page labelled with its number (#1308) (#1411)

The Print dialog turned Current page into the range "N" for the page on
screen, and ranges read numbers as page labels first. In a document
labelled i, ii, iii, 1, 2, viewing the first page printed the fourth,
whose label is "1".

Current page is now passed as a position, the way Selected pages is,
through print::select_listed. A typed range still reads labels first.

Adds print_ui::tests::current_page_is_the_viewed_page_not_a_label,
which fails before this change.

* fix(render): draw Turkish Ğ İ Ş ğ ş in non-embedded fonts (vendored hayro-interpret patch 26) (#1409)

A PDF whose fonts aren't embedded is drawn with PDFium's Foxit stand-ins
for the standard 14 fonts. Those carry only the 230-odd glyphs of the
Standard, WinAnsi and Mac Roman encodings, so a letter a font names beyond
them in its /Differences mapped to .notdef and drew nothing. iText 2
writes Turkish exactly this way (Times New Roman left unembedded,
/Gbreve /Idotaccent /Scedilla /gbreve /scedilla over Ð Ý Þ ð þ), and such
letters showed blanks for Ğ, İ, Ş, ğ and ş while ı, ç, ö and ü drew.

StandardKind now builds a Latin Extended-A letter the stand-in lacks from
the base letter and accent it does have, as Type 1 seac composites and
the standard-14 AFM composites are built: the accent is centred on the
letter, raised over capitals and letters with ascenders (and moved along
the slant in italics), cedillas hang below and ogoneks from the right
edge. Composite glyph ids sit above any font's glyph count, one per
character code; their width comes from the standard metrics of the name.

Test: raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts
(fails without the patch: Gbreve drew no pixel).

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

* M10: edit OCR text on scanned pages so the change shows (#1397)

* M10: edit OCR text on scanned pages so the change shows

Editing a recognised word on a scan rewrote it as invisible OCR text, so
the page still showed the old word, and the whole OCR layer grouped into
one paragraph that rewrapped when one word changed.

- An edited invisible paragraph is drawn visibly, over a cover in the
  paper colour sampled around it, in the scan's sampled ink colour
  (or the chosen colour).
- OCR words group into phrases: a table cell or a tab stop splits them
  and each scanned line stays apart; a phrase grows rightwards.
- Styled rewrites measure with the text matrix's horizontal scale, so
  stretched OCR words keep their widths instead of overlapping.
- The inline editor covers the scanned word and grows like the result.

* M10: size OCR text from each word's letters

A word box from recognition is tight around the ink, but the text layer
assumed every box spans Helvetica's ascender-descender height. A
capitals-only word such as "DT" spans only the cap height, so its text
was set about 23% too small and its baseline floated up; editing it
then drew the new word visibly smaller than the scanned one.

The em and baseline now come from the word's letters: cap height or
x-height on top, descender or baseline at the bottom. On a real scanned
form, a rewritten "DT" -> "DSI" now matches the scanned word's height
and baseline. The engine OCR-edit tests build their words the way a scan
would box them.

* Edit OCR text: sample colours only for invisible blocks, from the current bytes

Session::apply looked up every edited block with a fresh text_blocks pass and
re-serialized the whole document to render the sampling tile. Text blocks are
now cached per page on the editor (keyed on the unmodified document's source
identity, so any edit, undo or save invalidates them), visible blocks skip
sampling entirely, and the tile renders from the document's current bytes.
Adds a visible stretched-text regression test and documents that editing any
invisible text makes it visible over a painted cover.

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

* Edit OCR text: name the cached text-blocks tuple (clippy), rustfmt

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

---------

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

* Edit text: support per-font visual-order Indic text and nukta reordering (#1380)

* Edit text: support per-font visual-order Indic text and nukta reordering

- Detect legacy visual-order Indic fonts via Metrics.visual_order (fonts with
  Indic codepoints without OpenType GSUB tables, or legacy visual font names).
- Implement Indic visual-to-logical and logical-to-visual pre-base vowel
  reordering in crates/edit/src/indic.rs, with support for decomposed/precomposed
  nuktas and conjuncts.
- Apply reordering during extraction and source font reuse only when visual_order
  is set, leaving modern logical-order text untouched.
- Add unit tests for nuktas, conjuncts, and round-trips in crates/edit/src/indic.rs
  and font visual_order tests in crates/edit/src/tests.rs.

* Visual-order Indic: positive evidence only, ActualText kept logical, no font decoding

- A font is visual order only when it is a known legacy Indic family (subset tag
  allowed; aps-/dv- as prefixes) with an Indic Unicode mapping. A missing GSUB is
  no evidence (bare CFF and subset fonts drop it), so Metrics::from_dict no longer
  decodes every embedded font program on every call.
- Lines with /ActualText text are already logical: skip indic::to_logical, so
  "बढ़िया" no longer becomes "बढ़यिा".
- reading_lines no longer runs bidi recovery on every line (it changed RTL
  reading_blocks output for all documents); only the Indic recovery remains.
- indic.rs indexes through get().
- Tests use a ToUnicode fixture (not visual-order ActualText), cover an edit
  round-trip, logical ActualText in a legacy font, and a subset Noto font.

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

---------

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

* M12: preview text structure proposals for auto-tagging (#1383)

* M12: preview text structure proposals for auto-tagging

* Auto-tag preview: no unregistered command, allowed on copy-restricted files like a11y.check

The preview is read-only with no UI command yet, like render_preview, so it no
longer names the Planned a11y.autotag catalog id (tool_table_is_well_formed).

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

* M12: preview text structure proposals for auto-tagging

* Keep read-only auto-tag preview separate from unavailable command

---------

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

* M10: Test current-page print previews and saved output (#1415)

Extend the coverage of #1411 with preview raster selection and a dialog-driven PDF save test. Reopen and render the saved PDF to verify the viewed physical page when labels restart. Cover duplicate labels and selection guards without changing production code.

Refs #1308, #1411

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>

* fix(content): count an unreadable inline image with no EI as skipped (#1318) (#1418)

Follow-up to #1404. An inline image whose parameters can't be read and that has no EI after them ran to the end of the stream as a BI op with no data, and those bytes were not counted. It is now skipped like one with an EI, its bytes counted in Parsed::skipped. A stream that ends inside the parameters (BI /W 1) still keeps its BI op.

* feat(ui): add command palette arrow-key navigation (#1419)

* M4: Compare and cache actual Combine file duplicates (#1420)

Different equal-sized PDFs with the same name no longer receive duplicate warnings. Share exact-content detection between Grid and List, and cache the result using the existing list revision to remove the per-redraw all-pairs scan.

Cover distinct contents, actual duplicates, source parts, cache reuse, removal, undo, redo and closing the tab. Both UI regressions fail before the fix.

Fixes #1303

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>

* feat(preflight): verify image resolution and bleed without rewriting the file (#1408)

Squashed from the PR branch (its history added and removed a workflow file;
the final tree has no .github changes).

* printcheck: proper engine module, one TrimBox note, count unmeasurable images

Declares printcheck in engine/src/lib.rs instead of a #[path] module inside
ocr.rs. Pages without a TrimBox get one informational document-level
finding instead of a bleed warning per page; bleed is only measured
against a real TrimBox. Placements with a non-finite or degenerate matrix
are counted as resolution unknown (info finding, text line and JSON
field) rather than passing every check. Adds a print_readiness test
through Automation::call.

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

---------

Co-authored-by: Francesco Clemeno <checcomeno@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: umesh429 <umesh.ghanti@gmail.com>
Co-authored-by: umesh429 <3474974+umesh429@users.noreply.github.com>
Co-authored-by: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com>
Co-authored-by: TheLastRoadRunner <99679216+TheLastRoadRunner@users.noreply.github.com>
Co-authored-by: Tangu Achilis <43493154+TanguAchilis@users.noreply.github.com>
Co-authored-by: krinry <121806532+krinry@users.noreply.github.com>
Co-authored-by: Earl Co <earlvanze@gmail.com>
Co-authored-by: LabDennis <dennis@leistenschneider.pro>
Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
Co-authored-by: Pratham Bhanushali <134723366+Morpheus004@users.noreply.github.com>
2026-10-12 08:52:06 -04:00
LabDennis 4a44eeaa39
M4: Compare and cache actual Combine file duplicates (#1420)
Different equal-sized PDFs with the same name no longer receive duplicate warnings. Share exact-content detection between Grid and List, and cache the result using the existing list revision to remove the per-redraw all-pairs scan.

Cover distinct contents, actual duplicates, source parts, cache reuse, removal, undo, redo and closing the tab. Both UI regressions fail before the fix.

Fixes #1303

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
2026-10-12 08:39:39 -04:00
Pratham Bhanushali eab641ac8f
feat(ui): add command palette arrow-key navigation (#1419) 2026-10-12 08:38:47 -04:00
Marco Arnulfo d7590ebfbe
fix(content): count an unreadable inline image with no EI as skipped (#1318) (#1418)
Follow-up to #1404. An inline image whose parameters can't be read and that has no EI after them ran to the end of the stream as a BI op with no data, and those bytes were not counted. It is now skipped like one with an EI, its bytes counted in Parsed::skipped. A stream that ends inside the parameters (BI /W 1) still keeps its BI op.
2026-10-12 08:38:01 -04:00
LabDennis 291d738160
M10: Test current-page print previews and saved output (#1415)
Extend the coverage of #1411 with preview raster selection and a dialog-driven PDF save test. Reopen and render the saved PDF to verify the viewed physical page when labels restart. Cover duplicate labels and selection guards without changing production code.

Refs #1308, #1411

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
2026-10-12 08:37:11 -04:00
Earl Co 2839152af1
M12: preview text structure proposals for auto-tagging (#1383)
* M12: preview text structure proposals for auto-tagging

* Auto-tag preview: no unregistered command, allowed on copy-restricted files like a11y.check

The preview is read-only with no UI command yet, like render_preview, so it no
longer names the Planned a11y.autotag catalog id (tool_table_is_well_formed).

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

* M12: preview text structure proposals for auto-tagging

* Keep read-only auto-tag preview separate from unavailable command

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:20:13 -04:00
krinry e33b66e9b5
Edit text: support per-font visual-order Indic text and nukta reordering (#1380)
* Edit text: support per-font visual-order Indic text and nukta reordering

- Detect legacy visual-order Indic fonts via Metrics.visual_order (fonts with
  Indic codepoints without OpenType GSUB tables, or legacy visual font names).
- Implement Indic visual-to-logical and logical-to-visual pre-base vowel
  reordering in crates/edit/src/indic.rs, with support for decomposed/precomposed
  nuktas and conjuncts.
- Apply reordering during extraction and source font reuse only when visual_order
  is set, leaving modern logical-order text untouched.
- Add unit tests for nuktas, conjuncts, and round-trips in crates/edit/src/indic.rs
  and font visual_order tests in crates/edit/src/tests.rs.

* Visual-order Indic: positive evidence only, ActualText kept logical, no font decoding

- A font is visual order only when it is a known legacy Indic family (subset tag
  allowed; aps-/dv- as prefixes) with an Indic Unicode mapping. A missing GSUB is
  no evidence (bare CFF and subset fonts drop it), so Metrics::from_dict no longer
  decodes every embedded font program on every call.
- Lines with /ActualText text are already logical: skip indic::to_logical, so
  "बढ़िया" no longer becomes "बढ़यिा".
- reading_lines no longer runs bidi recovery on every line (it changed RTL
  reading_blocks output for all documents); only the Indic recovery remains.
- indic.rs indexes through get().
- Tests use a ToUnicode fixture (not visual-order ActualText), cover an edit
  round-trip, logical ActualText in a legacy font, and a subset Noto font.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:18:37 -04:00
Tangu Achilis 5cce1b247f
M10: edit OCR text on scanned pages so the change shows (#1397)
* M10: edit OCR text on scanned pages so the change shows

Editing a recognised word on a scan rewrote it as invisible OCR text, so
the page still showed the old word, and the whole OCR layer grouped into
one paragraph that rewrapped when one word changed.

- An edited invisible paragraph is drawn visibly, over a cover in the
  paper colour sampled around it, in the scan's sampled ink colour
  (or the chosen colour).
- OCR words group into phrases: a table cell or a tab stop splits them
  and each scanned line stays apart; a phrase grows rightwards.
- Styled rewrites measure with the text matrix's horizontal scale, so
  stretched OCR words keep their widths instead of overlapping.
- The inline editor covers the scanned word and grows like the result.

* M10: size OCR text from each word's letters

A word box from recognition is tight around the ink, but the text layer
assumed every box spans Helvetica's ascender-descender height. A
capitals-only word such as "DT" spans only the cap height, so its text
was set about 23% too small and its baseline floated up; editing it
then drew the new word visibly smaller than the scanned one.

The em and baseline now come from the word's letters: cap height or
x-height on top, descender or baseline at the bottom. On a real scanned
form, a rewritten "DT" -> "DSI" now matches the scanned word's height
and baseline. The engine OCR-edit tests build their words the way a scan
would box them.

* Edit OCR text: sample colours only for invisible blocks, from the current bytes

Session::apply looked up every edited block with a fresh text_blocks pass and
re-serialized the whole document to render the sampling tile. Text blocks are
now cached per page on the editor (keyed on the unmodified document's source
identity, so any edit, undo or save invalidates them), visible blocks skip
sampling entirely, and the tile renders from the document's current bytes.
Adds a visible stretched-text regression test and documents that editing any
invisible text makes it visible over a painted cover.

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

* Edit OCR text: name the cached text-blocks tuple (clippy), rustfmt

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:16:42 -04:00
TheLastRoadRunner a259af95c3
fix(render): draw Turkish Ğ İ Ş ğ ş in non-embedded fonts (vendored hayro-interpret patch 26) (#1409)
A PDF whose fonts aren't embedded is drawn with PDFium's Foxit stand-ins
for the standard 14 fonts. Those carry only the 230-odd glyphs of the
Standard, WinAnsi and Mac Roman encodings, so a letter a font names beyond
them in its /Differences mapped to .notdef and drew nothing. iText 2
writes Turkish exactly this way (Times New Roman left unembedded,
/Gbreve /Idotaccent /Scedilla /gbreve /scedilla over Ð Ý Þ ð þ), and such
letters showed blanks for Ğ, İ, Ş, ğ and ş while ı, ç, ö and ü drew.

StandardKind now builds a Latin Extended-A letter the stand-in lacks from
the base letter and accent it does have, as Type 1 seac composites and
the standard-14 AFM composites are built: the accent is centred on the
letter, raised over capitals and letters with ascenders (and moved along
the slant in italics), cedillas hang below and ogoneks from the right
edge. Composite glyph ids sit above any font's glyph count, one per
character code; their width comes from the standard metrics of the name.

Test: raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts
(fails without the patch: Gbreve drew no pixel).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:57:03 -04:00
Marco Arnulfo a4c53f706f
fix(ui): Current page prints the viewed page, not a page labelled with its number (#1308) (#1411)
The Print dialog turned Current page into the range "N" for the page on
screen, and ranges read numbers as page labels first. In a document
labelled i, ii, iii, 1, 2, viewing the first page printed the fourth,
whose label is "1".

Current page is now passed as a position, the way Selected pages is,
through print::select_listed. A typed range still reads labels first.

Adds print_ui::tests::current_page_is_the_viewed_page_not_a_label,
which fails before this change.
2026-10-12 07:55:39 -04:00
umesh429 a9a8ac63b7
fix(audit): an identity cm between re and fill no longer hides a cover (#1333) (#1410)
Co-authored-by: umesh429 <3474974+umesh429@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:54:52 -04:00
Marco Arnulfo 77c3f2003a
fix(automation): doc_save reports a required full rewrite as not incremental (#1329) (#1405)
doc_save reported "incremental": !full, from the arguments alone. When
an in-place save had to be a full rewrite (after doc_protect adds or
removes encryption, or after Sanitize), write_incremental fell back to
write_full, but the result still said the save was incremental.

The result now says whether the written file starts with the file it
was saved over, read from the new Document::saved_bytes. An ordinary
in-place save still reports true and an explicit full save false.

Adds an_in_place_save_reports_a_required_rewrite_as_not_incremental,
which fails before this change for both doc_protect and
doc_remove_hidden.
2026-10-12 07:54:17 -04:00
Marco Arnulfo 240ea53b8b
fix(content): skip an inline image with an unreadable key to its EI (#1318) (#1404)
When a key in an inline image dictionary was not a name (`BI /W 4 H 4
ID ... EI`), the parser ended the image at the end of the stream and
counted nothing as skipped, so every operator after the image was lost:
text_lines found no text on a page whose text follows such an image.

An image whose parameters can't be read is now skipped up to the EI
that ends it, the skipped bytes are counted, and parsing resumes after
it. Without an EI the image still runs to the end of the stream, as
before.

Adds tests::an_unreadable_inline_image_is_skipped_to_its_end, which
fails before this change.
2026-10-12 07:53:44 -04:00
Marco Arnulfo 2ee8ce1ac1
fix(forms): keep a whitespace-only value in a text field without a format (#1323) (#1403)
The commit-time Keystroke check returned an empty value for any
whitespace-only text before looking at the field's format, so setting a
space, a tab or a non-breaking space in a plain text field reported
success and removed the field's value. Text and XFDF imports go through
the same setter and lost the value the same way.

Blank input now clears only a field with a format (number, percent,
date, time, special or mask), as before; a field without one stores the
text as given. An empty value still clears any field.

Adds tests::whitespace_only_text_is_kept_in_a_field_without_a_format
and a keystroke assertion in af::tests::numbers_format_like_acrobat,
both of which fail before this change.
2026-10-12 07:53:10 -04:00
Marco Arnulfo 452ab51ab8
fix(sign): refuse to sign a field whose /V is not a signature (#1328) (#1402)
Follow-up to #1374. Signing an existing field refused a field whose /V
is a signature dictionary and signed one whose /V is missing or null,
but a /V that is neither (an empty string, a number, a name, an array)
was treated as unsigned and overwritten with the new signature.

Signing such a field by name now fails with "the value of the field
... is not a signature" and leaves the document as it is. A missing or
null /V, direct or indirect, still signs, and a signed field is still
refused as already signed. The signature list is unchanged: it still
shows such a field as unsigned, since it carries no signature.

Adds a_field_whose_value_is_not_a_signature_is_refused_by_name, which
fails before this change.
2026-10-12 07:52:34 -04:00
umesh429 6f49967459
fix(cli): edit refuses move/insert destinations past the end (#1330) (#1400)
Co-authored-by: umesh429 <3474974+umesh429@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:51:55 -04:00
Francesco Clemeno 0cb311d21c
M3: select an image on the page and copy it (#1363)
* M3: select an image on the page and copy it

Select tool: a click on a raster image selects it (also one drawn through Form artwork, or with
text over it such as an OCR layer); ⌘C copies it as a file in its original format (a JPEG as
stored in the PDF, otherwise a lossless PNG). Right-click ▸ Copy Image copies its pixels at full
resolution, and Copy Image in Original Format the file; Edit text & images has both for the
selected image. Both respect the document's copy permission.

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

* M3: translate the copy-image strings into Czech, Kazakh, Slovene and Turkish

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

* Copy image: keep the clipboard file in a private per-user folder

Copy Image in Original Format wrote into the shared temporary folder
(/tmp/pdfcraft-clipboard) and deleted everything in it first, so another
user on the machine could plant a link or folder there and have PdfCraft
delete or overwrite their target. The file now goes in PdfCraft's
per-user cache folder (or the portable data folder), which is checked to
be a real folder and set to 0700 on Unix (refused when it isn't ours).
Only earlier copies (regular files named '<stem> p<N> img<M>.jpg|png')
are removed, links are never followed, and the new file is created with
create_new so it can't be redirected.

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

* Tests: escape Windows paths in synthetic GoToR file specifications

canonicalize() returns \\?\C:\... on Windows; unescaped backslashes in a PDF literal string become control characters, so link_open refused the target and main's Windows CI failed. Taken from #1359's helper fix.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 07:47:35 -04:00
LabDennis efd68b377e
M7: Preserve explicit page ranges across dialog redraws (#1304) (#1382)
* M7: Preserve explicit page ranges across dialog redraws

The shared marks/export range widget treated 1..=1 as uninitialized and
expanded it to the document length on every redraw. A first-page-only
request consequently exported or modified additional pages.

Separate all-pages default handling from the endpoint widgets. Populate
the full document bounds only in all-pages mode; preserve custom ranges
and their odd/even filters.

Add synthetic UI regressions for stable ranges, first-page-only image,
text and embedded-image exports, and pixel comparisons of added/updated
header-footers, watermarks and backgrounds. All three regressions fail
before the fix and pass afterward. Update milestone progress.

Fixes #1304

* M7: Limit page-range documentation to the progress log

---------

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:24:18 -04:00
Ridwan 5bf6d17538
Issue #1368: keep text a dragged paragraph passed over (#1381)
Rewriting a paragraph also removed every line overlapping its box by more
than half, to clear fake-bold and repeated copies. Each drag is its own
edit, so after dropping one paragraph on another and dragging it on, the
covered paragraph was taken for a copy and deleted from the content
stream. A copy now also has to show the same text.

The same check is used when replacing a line, so a distinct line drawn
close enough to overlap (#1316) is no longer removed either.
2026-10-12 07:21:23 -04:00
Marco Arnulfo a0575f2478
fix(fonts): a /Differences code past 255 no longer encodes as its last byte (#1314) (#1394)
A simple font whose /Differences puts Euro at code 300 encoded "€" as
the single byte 0x2C, which the same font decodes as a comma. Simple
fonts show one-byte codes, so a larger code now has no glyph to encode
with: encode returns None and the caller falls back to another font.
The code stays in the decoding table as before.

Adds pdf::tests::differences_codes_past_one_byte_are_not_encoded,
which fails before this change.
2026-10-12 07:20:48 -04:00
Marco Arnulfo 24ccb5f3ae
fix(forms): a literal Off value reads as Off outside buttons (#1320) (#1393)
The field reader dropped the value "Off" for every field kind, so a
text field, combo box or single-select list set to "Off" read as empty
and exported an empty value to CSV, text, XML, XFDF and FDF, although
the document stored it. "Off" is now treated as no value only for
check boxes and radio buttons, where it is the off state.

Adds tests::a_literal_off_value_reads_as_off_outside_buttons, which
fails before this change.
2026-10-12 07:20:15 -04:00
Marco Arnulfo 3e2877cbbb
fix(forms): a whole look keeps the rest of an indirect /MK (#1322) (#1392)
set_props with a complete look read the widget's /MK without resolving
it, so an indirect one started from an empty dictionary: a check box
lost its Cross mark, its 90-degree rotation and any unknown entry, and
the loss survived a save. The /MK is now resolved first, as the
check_style branch already does; a direct /MK behaves as before.

Adds tests::a_whole_look_keeps_the_rest_of_an_indirect_mk, which fails
before this change.
2026-10-12 07:19:42 -04:00
Marco Arnulfo 0584425e67
fix(automation): content_update refuses text styling on an image (#1331) (#1391)
font, size, bold, italic, color and align were never read for an added
image, so content_update reported success, wrote the same image back,
marked the document dirty and added an "Edit content" undo step. They
are now refused with an error, as rotate, flip, crop and image already
are on text, before anything is applied.

Adds automation::text_styling_on_an_image_is_refused_without_an_edit,
which fails before this change.
2026-10-12 07:19:06 -04:00
Marco Arnulfo b6a1402bf3
fix(ui): the Fields panel lists a field on a missing page as unplaced (#1307) (#1390)
A widget whose /P is a page number past the end of the document (here
/P 99 in a one-page file) kept that page, and the Fields panel indexed
the page list with it to label the heading, which panicked. A page the
document doesn't have now gets the existing "Unplaced" heading.

Adds forms::the_fields_panel_lists_a_field_on_a_missing_page_as_unplaced,
which fails before this change.
2026-10-12 07:18:32 -04:00
Marco Arnulfo af1c997aff
fix(fonts): a composite /W run past the last CID stops there (#1313) (#1389)
A /W entry such as [4294967295 [100 200]] added the run index to its
first CID unchecked, so the second width overflowed: a panic with
overflow checks on, and with them off a width given to CID 0, which the
entry does not name. The run now ends at the last CID it can address;
/W [0 [100 200]] reads as before.

Adds pdf::tests::composite_width_run_past_the_last_cid_is_dropped,
which fails before this change.
2026-10-12 07:17:59 -04:00
LabDennis 2a004529dd
M14: Preserve recovery snapshots without readable metadata (#1378)
* M14: Preserve recovery snapshots without readable metadata

Recovery writes the PDF before its metadata sidecar. A failed sidecar write
or interrupted save could leave a usable snapshot that the next listing
silently deleted.

List PDF snapshots without deleting recovery files. When metadata is
missing, unreadable, corrupt or refers to another key, retain the snapshot
and recover it without an original path so saving requires a destination.
Keep the existing password flow for encrypted snapshots.

Add regression coverage for failed metadata writes, repeated listing,
missing/corrupt/mismatched metadata and encrypted recovery. Update the M14
progress documentation.

Validation: the failed-write regression fails before the fix; all 11
recovery tests pass after it. Formatting and the 31-crate WASM check pass.
The full workspace and strict Clippy gates remain red; see the PR body
for the failing tests and diagnostics.

* Recovery: replace damaged metadata once its snapshot is recovered; one progress-log entry

Recovering a snapshot whose sidecar was missing, unreadable or for another
key now writes fresh metadata for it (recording whether it is encrypted), so
the damaged file doesn't linger. clear() also removes sidecars whose PDF is
gone; snapshots themselves are never removed by listing. The header and M14
milestone-row edits are replaced by a single progress-log entry.

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

---------

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:13:35 -04:00
philip-tam 88d96c93a8
M2: snap shrunk interpolated images to whole pixels so scans stay sharp (#1377)
* M2: snap shrunk interpolated images to whole pixels so scans stay sharp

A scanned page (one 300 dpi image with /Interpolate true) was shrunk to a
ceil-sized raster and then sampled a second time at a fractional scale and
offset, which softened every edge next to Acrobat. An axis-aligned shrunk
interpolated image is now shrunk once to its rounded device rectangle and
drawn 1:1 at the rounded corner. Rotated, skewed, flipped, Type 3 and
non-interpolated images are unchanged.

On a synthetic scan page the mean error against an area-average reference
falls from 3.4 to 1.6 grey levels at 96 dpi (3.0 to 1.5 at 144 dpi).

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

* vendor: list the hayro snap patch as (14) in the Patches and Tests columns; correct gaps.md patch counts

The patch description had been appended to the Tests column of one of the
two hayro rows. gaps.md now counts the distinct patches vendor/README.md
lists (26 hayro-interpret, 15 hayro, 3 hayro-jbig2, 17 hayro-syntax).

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 07:12:02 -04:00
Yousef Mojahid 186c1dc767
M7: preserve Arabic text order in copy, search and editing (#1369)
* M7: preserve Arabic text order when copying and editing

Record per-line ActualText for shaped added text and preserve its logical order in Edit text. Cover mixed scripts, marks, copy/search, retyping and save/reopen; retain the drawing golden.

Assisted-by: Codex

* Edit text: keep only right-to-left ActualText out of line joining and bidi recovery

Word and Chrome wrap Latin ligatures in /ActualText spans mid-word; splitting
lines at every span broke such words into pieces in Edit Text. Only spans
whose ActualText has strong right-to-left characters keep their own line and
typing order now. Regression: a Latin fi ligature span stays on its line.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:11:23 -04:00
Marco Arnulfo 2796f3de0d
fix(print): refuse huge posters at the sheet limit instead of overflowing (#1335) (#1376)
A poster scale large enough to make both tile counts saturate at
usize::MAX overflowed when they were multiplied for the 1024-sheet
check: a panic with overflow checks, and a product that wraps to 1
without them, which let the sheet loops run. The headless doc_print
tool accepts any finite positive scale, so a script could reach it.

Multiply with checked_mul and treat an overflow as over the limit, so
the request gets the existing "more than 1024 sheets" error.

Adds tests::huge_posters_are_refused_at_the_sheet_limit, which panics
before this change and passes with and without overflow checks after it.
2026-10-12 06:27:50 -04:00
Marco Arnulfo 9593ff7419
fix(redact): removing comments keeps non-comment annotations (#1325) (#1375)
Remove Hidden Information counted and removed as a comment every
annotation that was not a link, widget, pop-up, file attachment or
redaction mark, so a Comments-only cleanup also deleted Watermark,
PrinterMark, TrapNet, Screen, Movie, RichMedia and 3D annotations.

Use pdfcraft_annot::is_comment_subtype, which the comments list already
uses, and keep excluding file attachments (removed with Attachments)
and redaction marks as before.

Adds tests::removing_comments_keeps_annotations_that_are_not_comments,
which fails before this change.
2026-10-12 06:27:04 -04:00
Marco Arnulfo f97f24cfbc
fix(sign): a signature field with a null /V can be signed by name (#1328) (#1374)
The signature list counts a field as signed only when its /V resolves
to a dictionary, but signing an existing field refused any field that
had a /V key. A field with `/V null`, or a /V pointing at a null object,
was listed as unsigned and then refused with "the field is already
signed".

Both now ask the field for its signature dictionary through one
predicate. A field with a signature is still refused, and a field
without /V still signs as before.

Adds a_field_with_a_null_value_is_unsigned_and_can_be_signed, which
fails before this change.
2026-10-12 06:26:18 -04:00
Marco Arnulfo 0108a5b07b
fix(preflight): find colour operators after any PDF whitespace (#1334) (#1373)
The PDF/A device-colour scan split content streams only on spaces and
line feeds, so `0 0 0 1 k` written with carriage returns, tabs, form
feeds or NULs between the tokens went unseen: verify reported no
DeviceCMYK, and convert gave the CMYK page an sRGB output intent.

Split on all six whitespace characters of ISO 32000-2 §7.2.3, using the
lexer's own `is_whitespace` (now exported from pdfcraft-cos) instead of
a second list.

Adds tests::device_colour_is_found_after_any_whitespace, which fails
before this change; its gray and RGB controls check that those pages
still get the sRGB output intent.
2026-10-12 06:25:42 -04:00
umesh429 27ce6f7584
CLI: audit rejects unknown options before reading the file (fixes #1332) (#1372)
`pdfcraft-cli audit` skipped option validation, so a misspelled
`--passwrod` fell through to a file-read error instead of naming the
bad option. Audit now runs `validate_options` first, like `info`, and
the options test covers it.

Co-authored-by: umesh429 <3474974+umesh429@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:25:06 -04:00
Marco Arnulfo ec5d9da36c
fix(xfdf): text data from a one-field form keeps its commas (#1321) (#1371)
The tab-delimited export of a single field has no tab, so import read it
as CSV and kept only the text before the first comma. CSV quotes a value
that holds a comma, so a single name over several unquoted cells is now
read as that tab-delimited text. Exported files are unchanged, and CSV
(quoted, multi-line, one or more columns) reads as before.
2026-10-12 06:24:27 -04:00
Earl Co d2d1b16b21
M5: adjust Fill and Sign text size (#1285)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:13:48 -04:00
Omar Elders d57f740003
Refine search match selection and text failure jitter handling (#1291)
* Fix search match selection order and text reload failure recovery

1. rerun_find: Accumulate and sort matches across all loaded pages and pick the first match on or after the currently viewed page before flashing once, avoiding arbitrary viewport jumps to page 0 when viewing subsequent pages. 2. receive_result: Gracefully fall back to the next match at or after the viewed page without flashing when page text extraction fails, avoiding latent None state that caused viewport jitter on subsequent page reloads. 3. Add tests verifying match selection on viewed page, continuous multi-page drawing with active search, and text failure recovery.

* rustfmt

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

* Clippy: RenderRequest is Copy

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:52:36 -04:00
Master f81e23bd4e
test(cli): reject unknown MCP options (#1342)
* test(cli): reject unknown MCP options

* Tests: wait longer for background text and rendering in viewer tests (FreeBSD CI flake); rustfmt

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:46:41 -04:00
cyfung1031 bf4b6799f6
ci: keep line tables for workspace debug info (#1298) 2026-10-12 05:45:51 -04:00
cyfung1031 6d9eb9c3ca
ci: run dependency layering once (#1286) 2026-10-12 05:45:13 -04:00
Brandon Thomas 78f00e45fe Release prep: 0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:35 -04:00
cyfung1031 ef844cefb4
CI: keep craft-fonts configuration stable across Linux gates (#1284) 2026-10-12 05:44:33 -04:00
Paul Spooren 9a8517599b
M12: accept literal digits in date formats (dd.mm.26) (#1352)
A date field formatted "dd.mm.26" (a leave form fixing the year) rejected
"15.05.26": parse_date read three numbers but the format only asked for a
day and a month. Digit runs in the format are now literal tokens that may
be typed or left out; a different number in their place is still rejected.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:43:55 -04:00
Jules Kreuer 47d612817a
M8: Clean indirect and form tag references (#1351) 2026-10-12 05:43:12 -04:00
nevicia2010-hub 51982db121
M3: match Greek final sigma in case-insensitive search (#1349)
* M3: use the macOS Finder label in the tab-menu regression

The UI already uses Reveal in Finder on macOS, but the regression test
queried Reveal in File Explorer on every platform. Select the platform's
label while preserving the disabled-action and inactive-tab assertions.

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

* M3: match Greek final sigma in case-insensitive search

Fold capital, normal and final sigma to the same character so Greek words
match in either case. Cover exact search, whole words, glyph ranges and
bounded matches without changing the existing Turkish-I behavior.

Record the search fix and the macOS test portability follow-up in the
roadmap. Workspace tests, all-target Clippy, formatting, wasm, layering
and a synthetic CLI before/after comparison pass on macOS.

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

---------

Co-authored-by: nevicia2010-hub <259836477+nevicia2010-hub@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 05:42:27 -04:00
Joel Tony 9d6552291b
Issue #1296: hayro-syntax applies SASLprep to AES-256 passwords (PdfCraft patch 16) (#1345)
Revisions 5-6 of the standard security handler key the file on the
password processed with SASLprep (ISO 32000-2 7.6.4.3.3, Algorithm 2.A
step a). pdfcraft-crypt writes and reads it so, but the vendored
hayro-syntax skipped the step ("TODO: Do the above.") and used the UTF-8
bytes as typed. A password that SASLprep changes (a decomposed "cafe" +
U+0301, a no-break space, fullwidth letters typed by an IME in full-width
mode) was wrong for the renderer: Protect with such an open password was
rolled back ("the password is incorrect"), and an AES-256 file with such a
password did not open in a session with it, although pdfcraft-cos
accepted it.

- hayro-syntax (vendored patch 16) tries an R5/R6 password in its SASLprep
  form (as pdfcraft-crypt computes it: as given if SASLprep refuses it,
  UTF-8, at most 127 bytes), then, when that differs, as given.
  stringprep, already in the tree through pdfcraft-crypt, is an optional
  dependency enabled by the std feature; without std nothing changes.
- Tests: hayro-syntax crypto::pdfcraft_password_tests (candidate list and
  R5/R6 dictionaries built from prepared passwords) and two engine tests
  (Protect and reopen, and pdfcraft-cos-encrypted files) for the three
  cases, with precomposed "caf\u{E9}" as the control. The dictionary test
  and both engine tests fail without the fix.

Fixes #1296.
2026-10-12 05:41:38 -04:00
MarekSpodBiedry 549fa7ae04
xtask: stop headless Chrome leaving macOS code-sign clones (#1196) (#1292) 2026-10-12 05:40:50 -04:00
ysrdevs 4b45fc9cb4
XFA: one script thread per event, and the live form kept up to date in place (#1355)
* XFA: one script thread per event, and the live form kept up to date in place

A form with hundreds of calculate scripts used to spend its open-time
budget on set-up: every script got a thread of its own, and the engine
dropped its live form after every value a script settled, rebuilding
the whole tree for the next script.

The js crate gains xfa_session::XfaSession: the scripts of one event
run on one thread (with the script stack), each in a fresh JavaScript
context or FormCalc interpreter on a copy of the form as the engine
holds it then, so scripts share nothing, a script that fails halfway
leaves nothing behind, and what a script changed reaches the next one
only through the engine. A script past its time limit is abandoned on
the thread and the session is dead from then on; a fault in the engine
(a panic) loses only that script, the thread is replaced. On wasm the
scripts run inline, as before.

The engine's runner keeps one session per event and, for a value, now
refreshes the one field in its live form in place (xfa::refresh_value,
computing the value as the tree builder would: saved values, template
defaults) instead of rebuilding the tree; rows, presence, access,
resets, relayouts and data rewrites still rebuild it. Under test, the
live form is compared with a fresh build before every script and at
the end of every event, and a difference ends the test binary (a panic
would be caught by the engine's guard and turned into a script error).

210 calculate scripts (one per row of 10 sections x 20 rows, plus a sum
per section), debug build, instrumented inside the engine: the script
event on open 495 ms -> 204 ms (of which live-form rebuilds 217 ms ->
0), on a change 300 ms -> 172 ms; through the tools, layout included,
open 1.27 s -> 0.97 s and a change 0.44 s -> 0.22 s. The rebuild cost
removed grew with form size times script count.

* CI: the GoToR tests' case lists are only grown on Unix, so Windows must not warn on their mut
2026-10-12 05:39:49 -04:00
Bryan Mark 0e55d07b5f
M12: say when a form's scripts were turned off (#1251)
* M12: say when a form's scripts were turned off

When an XFA script runs past its time limit it is abandoned and the
form's scripts are turned off for the document. The only trace was an
error line in the JavaScript console, so calculations and buttons just
stopped working.

- Engine: Document::xfa_scripts_off() reports the state (set on open and
  on an edit), so a viewer shows it without reading error text. doc_info
  reports it to agents as xfa_scripts_off.
- App: when it happens (at open or after a click) a toast says so once,
  and the notice bar says so from then on, ahead of its other notices,
  with a JavaScript Console button. A notice bar the user had closed
  comes back for it; closed again, it stays closed.
- The sentence is translated in the 17 catalogs that are complete
  (hashes in ATTRIBUTION.toml updated).

Tests: two kittest UI tests (runaway at open; runaway click after the
bar was closed), which fail without the app change; engine assertions
for both states; a doc_info tool test.

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

* Catalogs: the scripts-off notice in Greek, Polish and Telugu; refresh hashes

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 03:42:56 -04:00
Krzysztof Cieślik 7d419a6c73
feat(edit): remove all links command and context menu (#539) (#1213)
* feat(edit): remove all links command and context menu (#539)

- Expose 'edit.remove_links' and 'edit.remove_all_links' commands to remove all links across the document or on specified pages.
- Add right-click context menu options for links: Properties, Delete, and Remove all links in both Link tool and Select tool modes.
- Extend automation tool 'links_remove' with optional 'pages' parameter and removal count.
- Add notification feedback in UI for link deletion.
- Mark 'edit.remove-all-links' as shipped in Parity with evidence tests.
- Add unit and integration tests covering automation, engine, and UI undo/redo.

* Remove all links: one command id, catalog rows, link menu only on a link

- Drop the duplicate edit.remove_all_links command (same label as the
  existing edit.remove_links, so it showed twice); edit.remove_links now
  runs remove_all_links with its toast.
- Add the toast strings (No links to remove, Removed 1 link, Removed {n}
  links) to all 20 catalogs.
- The canvas context menu shows the link items only when the right-click
  hit a link (LinkView::context, set per right-click), so a link left
  selected no longer takes over the comments menu, and the Link tool no
  longer replaces the comments menu away from links. Regression test.

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

* Restore the PR's links_remove pages test (lost with the workdir conflict)

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:31:39 -04:00
git-monchiant e8e275cbd4
Text extraction: honour /ActualText, so Thai from Chrome PDFs is searchable (#1217)
* Text extraction: honour /ActualText, so Thai from Chrome PDFs is searchable

Chrome (Skia) writes /ActualText around each Thai, Indic or Arabic cluster
whose shaped glyphs (a tone mark moved clear of a vowel, a ligature) have no
Unicode of their own. Text extraction ignored it, so those marks were lost:
"น้ำหนัก" came out as "นาหนัก", "เป็น" as "เปน", and searching for any
such word found nothing.

- hayro-interpret patch (25): Device::actual_text hands the device a
  marked content sequence's /ActualText, inline or named in /Properties.
- TextDevice turns the glyphs of a replacement into one glyph carrying the
  replacement text, boxed around them. The outermost replacement wins, an
  empty one stands for no text, and one spanning several lines or writing
  directions keeps per-glyph text so selection and lines still work.
- Canva draws text twice (outline or shadow) and marks only one drawing:
  the unmarked redraw at the same places takes the same replacement, so the
  existing redraw detection still drops the copy.

Checked on 1,114 real-world PDFs against the previous build, with pdftotext
as the reference: 957 extract byte-identical text, 157 change and all of
them read closer to the reference (exact matches 3 -> 122), none got worse,
none newly fail, and total extraction time is unchanged.

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

* Text extraction: bound the work of the ActualText redraw dedupe

`TextDevice::finish()` compared every replacement with every unmatched glyph
in up to 4096 grid cells, so a hostile page stacking ~10^5 glyphs and ~10^5
replacements at one spot meant ~10^10 comparisons (a hang). It now charges
each visited cell and each candidate run against a per-page budget
(`MAX_REDRAW_WORK`, 2^22) and stops deduplicating once it is spent; redraws
found so far are still merged. A regression test feeds 100,000 replacements and
100,000 non-matching glyphs at one place.

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

---------

Co-authored-by: git-monchiant <4667243+git-monchiant@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 03:28:27 -04:00
cyfung1031 fcf6e39d46
M14: tune Windows low-DPI JetBrains Mono hinting (#1264)
* M14: tune Windows low-DPI mono hinting

* Theme: ignore the raster-sharpness probe test

`windows_hinting_improves_low_dpi_mono_raster_without_changing_layout_or_coverage`
asserts exact egui rasterisation deltas that move with egui upgrades; it is now
`#[ignore]` (run with --ignored when retuning). The tweak and its policy test
stay.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:25:25 -04:00
srt8hellcat2-ui 06480e395d
M3: add PDF tab context menu (fixes #1233) (#1266)
* feat(ui): add PDF tab context menu (#1233)

* Tab menu: reveal through D-Bus off the UI thread

`dbus-send --print-reply --reply-timeout=2000` (and the xdg-open fallback)
could block the frame for 2 s or more; on Linux and FreeBSD the reveal now runs
on a `pdfcraft-reveal` thread and a failure is logged. The existence check stays
on the UI thread and is still reported; Windows and macOS launches are
unchanged. Refreshes the catalog hashes after the main merge.

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

---------

Co-authored-by: srt8hellcat2-ui <225485253+srt8hellcat2-ui@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:15:02 -04:00
ngrafton2006 7183bf4264
Comments: "Other colour…" picker and recent custom colours under the swatches (#1193) (#1261)
* Comments: "Other colour…" picker and recent custom colours under the swatches (#1193)

The annotation tools offered only the ten fixed swatches. The shared swatch
grid (comments' quick bar, right-click menu and Comment Properties, and the
other colour choosers that use it) now ends with "Other colour…", a full
colour picker that starts from the current colour; Apply commits it once.
Applied custom colours are kept as a "Recent colours" row (newest first,
five at most, swatches not repeated) for the session.

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

* Comments: import kittest's Queryable in the Other colour test (#1193)

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

* Comments: "Other colour…" is a "+" swatch so the grid keeps its height (#1193)

A row under every swatch grid made dense dialogs (Redaction Tool
Properties has two grids) taller, pushing their lower controls out of
reach. The picker now opens from a swatch-sized "+" at the end of the
first row; the Recent colours row appears only once a custom colour has
been applied.

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

* Comments: each colour grid keeps its own "Other colour…" state

Two swatch grids drawn in one parent (Redaction properties, Prepare a form) shared the
picker's open state, so "+" opened both. The state and the swatch grid are now keyed on
each grid's own place in its parent.

* Comments: test that two colour grids in one parent open separately

* ATTRIBUTION.toml: catalog hashes after merging main

---------

Co-authored-by: ngrafton2006 <ngrafton2006@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 03:11:33 -04:00
Earl Co 4e53e5ffbd
Tests: normalize generated streams in Arabic output golden (#1275)
* Make Arabic output golden independent of Flate backend

* Arabic golden: keep /Filter and /DecodeParms in the hashed bytes

Normalise only the raw stream bytes (and so /Length, which the writer
takes from them), so a change that stops compressing generated streams or
switches filters still fails the golden. Fingerprint recomputed (same with
and without craft-fonts).

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:54:21 -04:00
Earl Co 51359beaea
Keep manual review visible in accessibility summaries (#1224)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:52:35 -04:00
Eduardo Soares 4541974ef9
Reading colours: replace document colours for reading comfort (#897) (#1270)
* test: give faux-redaction audit its own workdir (parallel-test flake)

* hayro: ColorReplacement type and luminance mapping (reading colours, #897)

* fmt: engine dates (upstream Greek localization miss)

* hayro: apply ColorReplacement to solid text and line-art paints (#897)

* render: ReadingColors view setting flows into RenderSettings (#897)

* engine: per-document reading colours state (#897)

* automation: view_reading_colors tool; page_render honours it (#897)

* ui: Reading colours preference (Replace Document Colors, #897)

* ui: seed reading colours on revision and signed-version opens (#897)

* parity: a11y.replace-page-colors shipped (#897)

* render: map stencil-mask paints through reading colours (#897)

An ImageMask stencil's solid paint was baked into the raster bytes raw,
bypassing the reading-colours hook every other solid fill goes through:
under white-on-black, CCITT scans, JBIG2 text and stencil logos drew
black ink on the black mapped background and vanished. Route it through
replaced(paint, false) — stencils are line art in disguise — and cover
it with an ImageMask fixture test sampling mapped ink on a mapped
background.

* render: print previews render with reading colours off (#897)

Print previews went through the document's render pool, whose config now
carries the reading colours: a white-on-black user saw black sheets in
the Print dialog while the actual print stayed uncoloured. The request
tag is the per-request channel that already crosses the render boundary,
so PRINT_TAG now lives in pdfcraft_render and the renderer honours it by
rendering previews with reading colours off — a preview's job is
fidelity to the printout. The main view stays recoloured. Covered on
both render paths (inline PageRenderer and pool worker).

* ui: seed reading colours on created-document opens (#897)

open_created (comment summaries) and open_created_bytes (images, text,
blank pages, clipboard) pushed new DocViews without seeding the reading
colours, unlike every other open path. A created document now starts
with the app-wide preference too; the seed-on-open test drives both
paths.

* docs: reading-colours flag echo and display-fidelity parity notes (#897)

The view_reading_colors description now says a flag that differs from
the mode's preset default makes the echoed mode read custom, and the
a11y.replace-page-colors parity notes record that print previews and
view snapshots render with the document's true colours: reading colours
apply to the reading view only.

* Merge follow-ups: reading colours beside preview renders and the new catalogs

- Renderer::new carries both color_replacement and the new preview flag
- our raster tests construct RenderRequest with preview: false
- the preference dialog grew past the window: scroll the combo into view
  and give the fixture a valid xref so no repair banner covers the dialog
- checkboxes use the crate's square-box widget (clippy disallows egui's)
- Kazakh and Turkish catalogs gain the ten reading-colours strings

* Reading colours: record the hayro patch in vendor/README.md; add missing translations

vendor/README.md gains hayro patch (13): `RenderSettings::color_replacement` /
`ColorReplacement`, the `render_pdf` signature change and the removed
`[dev-dependencies.log]`, with its tests. Adds the ten reading-colours labels
to el, es, pl, te and zh-hant.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:49:31 -04:00
Jeremy Yao 8cacb44728
M3: follow Windows' Animation effects setting; add an Animation effects preference (#1235)
* M3: follow Windows' Animation effects setting; Smooth scrolling preference

With Settings > Accessibility > Visual effects > Animation effects turned
off, the wheel still eased: egui spreads a coarse wheel step over about a
quarter of a second, for scrolling and for Ctrl-wheel zooming, and has no
switch for it.

- crates/ui-egui/src/motion.rs reads each frame's wheel input the way egui
  does, minus the easing, and hands it to the scroll areas and the views
  (zoom_delta). A test sums what egui eases and compares it with what is
  read at once, and fails if egui stops easing.
- With the system asking for no animations, egui's own animations take no
  time and the progress card's bar steps without its sweep.
- The desktop app asks WinRT's UISettings.AnimationsEnabled at launch and
  every two seconds of use, so a change applies without a restart.
- Preferences has Smooth scrolling: Use system setting (default), On, Off.
  On or Off decides the wheel's scrolling and zooming, and scrolling to a
  place, whatever the system says. Saved, settable as smooth_scrolling on
  the control channel, translated in all 20 catalogs.

How far a notch scrolls or zooms is unchanged. macOS, Linux and the
browser aren't asked for their setting yet; Off works everywhere.

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

* Credit Jeremy Yao by name in About (contributors/people.toml)

My own entry: real name and display name "Jeremy Yao".

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

* M3: rename Smooth scrolling to Animation effects; it decides every animation

The preference also governs Ctrl-wheel zooming, so "Smooth scrolling"
described only part of it. As "Animation effects" (Use system setting, On,
Off) it now decides every animation in the window, the way the Windows
setting of the same name does:

- the wheel's scrolling and zooming, and scrolling to a place (as before);
- egui's own animations, the progress card's sweep, and the chrome's hover
  and press washes (new on main, with durations of their own), which used
  to follow the system alone or not at all.

Saved and set on the control channel as animation_effects (system, on,
off); the unreleased smooth_scrolling name is gone. The three labels are
retranslated in all 20 catalogs.

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

* Tests: the app's two real-GPU tests take turns (FreeBSD SIGSEGV)

FreeBSD CI died with SIGSEGV in the pdfcraft binary's tests on this branch
(d4b928f1). The log shows a_gpu_error_while_a_window_is_set_up... (started
first, still running) and pipeline_creation_error... (started shortly
before) both in flight when the process died: two test threads inside the
graphics libraries at once. Nothing else still running was native code.

Both now take one lock for the whole test, as the UI tests already do for
the same reason (crates/ui-egui/tests/view.rs). Each takes about two
seconds with a device, which is why they can overlap.

The FreeBSD job has been failing at the same step with the same exit code
on main (84a75eae, 4a1e205f, 3d2bdfe7: every completed run since #1042
added the second real-GPU test) and on unrelated PRs. Their logs weren't
available to confirm it is the same crash.

Not reproduced locally (no FreeBSD machine here); the next FreeBSD run is
the check.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:46:40 -04:00
cyfung1031 c1d98946a9
M7: open local GoToR PDF links natively (#1265)
* M7: open local GoToR PDF links natively

* GoToR links: read only regular .pdf files under a size cap; add missing translations

A GoToR link in an untrusted PDF could name /dev/zero (unbounded read), a FIFO
(UI thread hangs) or a directory. Both the UI path and the `link_open` tool now
go through `pdfcraft_engine::read_remote_document`, which requires a `.pdf`
extension and a regular file (metadata checked before opening) of at most
1 GiB, and stops reading if the file grows past the cap. Regression tests cover
directories, a /dev/zero symlink and a FIFO (unix) in the engine, the tool and
the UI.

Adds the four link notifications to all 20 catalogs and "The existing action
will be preserved." to el, es, pl, te and zh-hant.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:34:19 -04:00
Raj Patel 2aea7dcfeb
M10: print from the web build through the browser's print dialog (gap 4) (#1243)
* Gates: fix two clippy failures on main

- ocr_ui: "Also recognize pages that already have text" used egui's own
  checkbox, which clippy.toml disallows (#1184 landed beside #1173);
  use widgets::checkbox like every other box.
- camera_edges: Rust 1.97's manual_range_contains; the rewrite keeps the
  old answer for a NaN angle (false) with an is_finite check.

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

* M10: print from the web build through the browser's print dialog (gap 4)

The web build had no printer: spool::submit refuses on wasm, so Print
could only download the print-ready PDF. Now its Print dialog offers
"Browser's print dialog" (the default there), which reaches every
printer the operating system knows.

- web_print: PdfCraft draws the print-ready PDF's sheets itself
  (engine Exporter), one sheet a frame since wasm has no threads:
  300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000, at most
  64 MP a sheet and 1 GiB of PNGs a job. The PNGs become blob URLs
  and print from a hidden srcdoc iframe, one sheet per page, margin 0.
- Chromium prints every page at the default @page size, so a sheet of
  the paper's size turned sideways is rotated 90 degrees CCW onto it
  (as drivers do); other sizes get named @page rules.
- Copies, two-sided and colour are greyed out (the browser's dialog
  sets them). If print() is refused (e.g. a sandboxed frame), the
  print-ready PDF downloads instead, with the reason.
- pdfcraft-print stays free of web APIs; six new strings in all 20
  catalogs (ATTRIBUTION hashes refreshed); docs and parity updated,
  gap 4 closed.

Verified in headless Chromium against a trunk build: three sheets at
300 dpi, the landscape sheet turned, no blank trailing page, and the
download fallback. Firefox and Safari not yet tried.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:31:36 -04:00
Earl Co b1784fc2d9
Accessibility: add visual tag properties panel (#1216)
* Expose tag parent and depth for hierarchy view

* Accessibility: add visual tag properties panel

* Show collapsible tag hierarchy in accessibility panel

* Open checker findings in the matching structure tag

* Translate Tags panel across supported languages

* Tags panel: walk the structure tree once per edit, not every frame

The Tags panel and checker findings that open a tag share a tag list cached per
document and edit generation.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:28:56 -04:00
Earl Co e4527a855f
Accessibility: persist human review decisions for an exact PDF revision (#1239)
* Save accessibility review decisions against PDF revision

* Accessibility review sidecars: read them with a 1 MiB cap

The sidecar path is the caller's and may name any file, so reading it
whole was an unbounded allocation. Stop past 1 MiB and refuse an
oversized file as not a sidecar (load fails, save leaves it alone), like
malformed content. Tests cover malformed, binary and oversized files.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:10:50 -04:00
Krzysztof Cieślik bde9dc52fe
honour the /Lab colour space /WhitePoint (#1203) (#1237)
* fix: recognize paragraph drafts as unsaved work (Issue #811)

* fix(color): honour the /Lab colour space /WhitePoint (#1203)

The old path fed every Lab value to the bundled ICC transform as if the
space's white point were D50, so a /Lab space with any other /WhitePoint
(e.g. D65 [0.9505 1 1.089]) rendered off by several levels per channel
and a D65 blue came out 45 levels too red.

Map L*a*b* to XYZ in the space's own white coordinates (ISO 32000-2
§8.6.5.4), Bradford-adapt it to D65 and convert to sRGB directly, as
Poppler does. Missing or degenerate white points fall back to a flat
white and hostile components are clamped without panicking.

Add a render regression that checks the five reported flat patches of
the D65 white-point fixture against their CIE sRGB values.

* Lab: map normalised Indexed palette components onto L*/Range; drop unused LAB.icc

An /Indexed palette over Lab passes its entries as byte/255 (manual_scale),
which the new Lab conversion read as raw L*a*b* and drew near black. Map
them onto L* 0..100 and the a*/b* /Range, with a regression test. Record the
patch as hayro-interpret (25) in vendor/README.md, and remove LAB.icc (no
longer referenced) with its ATTRIBUTION, NOTICE and assets README entries.

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

* Main gates: rustfmt tests/viewer.rs; a type alias for the refresh-steps array (clippy type_complexity)

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:08:21 -04:00
komo1iddin 13637b8440
M7: transform private image crops across nested states and streams (#1269)
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:38:32 -04:00
Γιῶργος (George) 5a91d1b7d0
fix(i18n): refine Greek translations and May abbreviation (#1262)
Co-authored-by: George Athenakis <2944375+Camthalion77@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:35:56 -04:00
Earl Co 8256744331
Export accessibility review comparison as HTML (#1234) 2026-10-12 01:34:08 -04:00
Earl Co 829afbc985
Move sibling structure tags in reading order (#1229) 2026-10-12 01:33:55 -04:00
jcl-bit 26a68d9765
Units: read the Windows measurement system from the registry, not reg.exe (#1227)
The desktop app started `reg.exe query "HKCU\Control Panel\International" /v iMeasure` at every launch to pick the default Units (#463). On managed PCs reg.exe is a process EDR and WDAC/AppLocker setups watch, and a Sandbox process-creation audit showed it as the one child process left at startup. Read the value in-process with winsafe's safe RegGetValue (advapi feature) instead. Behaviour is unchanged: "1" is inches, "0" is millimetres, anything missing or unreadable falls through to the locale check. The value-to-unit mapping is a pure function with a unit test, plus a Windows test that the registry read yields a known value.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:33:41 -04:00
srt8hellcat2-ui 4812e49992
M10.5: load driver options for CUPS printer instances (#1226)
Use the queue component of a validated CUPS destination to locate its PPD,
while preserving the full queue/instance name when querying lpoptions.
Cover instance defaults and command arguments with synthetic PPDs and a
stand-in process, retaining path validation and the existing public API.

Co-authored-by: srt8hellcat2-ui <225485253+srt8hellcat2-ui@users.noreply.github.com>
2026-10-12 01:33:29 -04:00
SteamSmith 486b1bbcb1
control: close the connection on malformed input before auth (#1259)
control::serve documents that the first request on a connection must be
`auth` and that anything else closes the connection. A first line that
wasn't valid JSON got a parse error but left the connection open, unlike a
wrong token or a non-auth request. Close it in that case too; after a
successful auth a malformed line still just gets the parse error.

Adds control::transport_tests::malformed_input_before_auth_closes_the_connection,
which fails before this change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:33:06 -04:00
Earl Co 9c8cf1a701
Add case and whole-word options to redaction search (#1258) 2026-10-12 01:32:53 -04:00
Raj Patel d0975b974d
M5: quick-bar colour recolours the selected comment (#1191) (#1255)
* Fix selected comment quick-bar color (#1191)

* M5: skip quick-bar recolour when commenting is forbidden (#1191)

The quick-bar colour swatch now checks allows_annotation() before
restyling the selected comment, matching the canvas and context-menu
gating. Without it, a restricted document showed a "Change comment
properties failed" toast on every swatch pick; the engine already
rejected the edit. Adds a regression test on an encrypted, print-only
fixture that fails without the guard.

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

* M5: quick-bar colour also recolours a selected arrow (#1191)

Arrows are saved as Line annotations with an ending, so tool_for()
reports them as the Line tool and the Arrow tool's swatch never matched
the selected arrow. comments::made_by() treats the Line and Arrow tools
as the same kind. Adds a regression test that fails without the fix.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-12 01:32:40 -04:00
Earl Co f9bec964cb
Accessibility: compare checker results after each recheck (#1250)
* Compare accessibility checks after document fixes

* Assert both pre-fix title findings in comparison test
2026-10-12 01:32:26 -04:00
Diego Ospina ef49fad861
M5: Insert text snaps its caret to the text (#345) (#1249)
On upright horizontal text of unrotated pages the caret's tip goes to the
nearer character edge on the baseline, sized to the text; elsewhere it stays
at the click. The size is mapped to user space, so /UserUnit pages match.
Parity stays partial: snapping is UI-only.

AI-assisted (Claude Opus 5.5), reviewed by GPT-5.6-terra (Codex CLI).
Native review welcome.

Co-authored-by: Diego Ospina <diegozpy@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:32:12 -04:00
cyfung1031 5cccbd6ba5
M7: report unsupported link actions without a JavaScript claim (#1242)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:31:50 -04:00
srt8hellcat2-ui b939becb06
M0: validate the committed parity manifest in xtask tests (#1214)
Parse the committed parity manifest in the existing xtask unit-test suite
so duplicate TOML keys fail cargo test --workspace.

Co-authored-by: srt8hellcat2-ui <225485253+srt8hellcat2-ui@users.noreply.github.com>
2026-10-12 01:30:08 -04:00
cyfung1031 b84ab4507a
M13: isolate automation workdirs by call site (#1223) 2026-10-12 01:29:56 -04:00
Omar Elders 31938de20a
Fix viewport jitter when annotating with search active (#1254) (#1283)
When an annotation was added or edited, page_changed cleared search matches for the page and reset f.current to None. When the background text reload finished, refresh_find_page saw f.current.is_none() and invoked flash_match(), setting view.goto and causing the canvas viewport to jump after every drawn element.

- Preserve search state in page_changed instead of prematurely clearing matches and current match index.
- In refresh_find_page, gate flash_match() so it only jumps when no match was previously selected (e.g. initial search query), avoiding viewport jumps during background text reloads.
- Add regression test marking_while_searching_does_not_jitter_or_reset_search_state.

Fixes #1254
2026-10-12 01:29:39 -04:00
Earl Co 05a862995d
Accessibility: show figure findings on the page (#1280)
* Highlight locatable accessibility figure findings

* M12: locate annotation and form accessibility findings

* M12: keep one contributor profile entry

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:29:25 -04:00
Raj Patel 1970935587
M14: edits let go of the previous working file before writing the next (gap 13) (#1279)
* M0: keep the camera edge test NaN-safe under clippy's manual_range_contains

Clippy 1.97 flags `d < 0.35 || d > PI - 0.35` in the camera edge hit test. Its suggested
`!(0.35..=PI - 0.35).contains(&d)` would count a NaN direction as a hit, which the two
comparisons did not, so the NaN check is kept explicitly. Behaviour is unchanged.

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

* M14: edits let go of the previous working file before writing the next (gap 13)

Every edit writes a new working file: the original plus one appended revision. The previous
one stayed alive in the working bytes, the display bytes and the renderer while the next was
written, so after a few edits a 1 GiB file peaked at three copies.

Each refresh path (full, comments/form, content, undo, redo, after save) now releases them
first: the working and display bytes fall back to the original (already held by the editor)
and the renderer is replaced by an empty one until the refresh rebuilds it. A refresh that
still fails after rollback draws the original instead of nothing. Save still writes from the
editor, never from these.

Measured on a synthetic 1 GiB file (release build, Linux): five edits peak at 2.1 GiB instead
of 3.1 GiB, in 8.5 s instead of 16 s. Saving still peaks at three copies; reading from disk on
demand stays blocked on the renderer (hayro parses one contiguous buffer, gap 2). gaps.md,
the parity notes and the roadmap record both.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-12 01:28:01 -04:00
DWAA1660 c52072b678
Add editable page-range selection to Organize Pages (#1092) (#1174)
* #1092: Add editable Organize page-range selection

Generated with Codex.

* #1092: Leave overlapping main CI cleanup to PR #1167

Generated with Codex.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 01:25:38 -04:00
Krzysztof Cieślik 84a75eae1b
M10: Presentation (.pptx) export (#1137) (#1162)
* feat: presentation (.pptx) export (#1137)

- Implement OpenXML PresentationML (.pptx) generator in crates/export/src/pptx.rs:
  - Generate [Content_Types].xml, _rels/.rels, core properties, presentation.xml
  - Build slide master, blank slide layout, and DrawingML theme
  - Render slides per page: largest text block as title shape, remaining paragraphs as body text box with font size, bold, italic, and color formatting
  - Embed images with proper DrawingML picture shapes and relationships
  - Generate DrawingML table shapes with column grid and span merging
- Wire OfficeFormat::Pptx into crates/engine/src/compare.rs and export_office
- Register export.pptx command and mark Presentation (.pptx) Ready in catalog
- Support pptx in automation doc_export_office tool
- Wire export.pptx into UI commands and file save dialog
- Add unit and integration tests across crates/export, engine, automation, and ui-egui
- Update create.export-pptx parity feature evidence and docs

* pptx export: saturating slide positions, matching cell-border tags, theme ea/cs fonts, package test

- Heights and the running vertical position use clamped/saturating arithmetic, so a hostile
  text size or image rectangle (1e300, NaN, inf) can't overflow i64; the item loop uses get().
- The table cells' a:lnR and a:lnT borders were closed with </a:lnL>, which made every slide
  with a table malformed XML; they now close with their own tags.
- The theme's major/minor fonts carry the schema-required a:ea and a:cs children.
- New test walks the produced zip entry by entry (sizes, inflation, central directory), checks
  every XML part's tags balance, the required parts, content-type overrides, relationship
  targets and that no placeholders are used; a hostile-sizes test covers the overflow.
- Test literals gain main's Block/Image rotation and size fields.
- Parity notes say what is still unverified.

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

---------

Co-authored-by: KrzysztofC <krzysztofc@localhost-live.lan>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:55:25 -04:00
Fabio Lussi 0a447ee1ab
Comments panel: show Persian and Arabic comments in reading order (#1079) (#1138)
* Comments panel: show Persian and Arabic comments in reading order

Comment bodies and replies with right-to-left script were laid out with
their words in logical order, left to right. They now wrap through
bidi::wrap: each paragraph takes its direction from its first strong
character, lines break in reading order, each line is reordered (UAX #9)
and right-to-left lines sit against the right edge. Author names get the
same treatment on one line. Persian and Arabic-Indic digits, which
egui's shaper drew reversed, now read left to right, file names
included.

Fixes #1079

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

* RTL comments: cache wrapped lines between frames

wrapped_text measured every candidate line of every right-to-left comment on every frame.
The wrapped lines are now kept in egui's temp data, keyed by a hash of (text, font, width,
pixels per point, collapsed); the cache starts over at 256 entries, so it stays bounded.
A collapsed comment wraps only its first 1024 characters (it shows one line), still with
the ellipsis when the text goes on. Test: second frame reuses the wrap, the cache stays
bounded, a very long collapsed comment is one line.

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

---------

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:22:12 -04:00
Earl Co a8940f0003
M10: Create separate PDFs from multiple files (#1183)
* M10: Create separate PDFs from multiple files

* Separate PDFs: translatable notices

"Left out: {names}" and the per-file reasons (already a PDF, no unused output name, no
output folder was chosen) go through tl!; the browser's download notice is a plural
("Downloaded {n} PDF" / "Downloaded {n} PDFs") via trn. Rows in all 20 catalogs, with
each language's plural forms; catalog hashes refreshed.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:20:13 -04:00
Eduardo Soares 012b34a2a5
M4: rename colliding form field names when combining (organize.field-name-conflicts) (#1155)
* M4: rename colliding form field names when pages are imported or combined

Insert Pages from File, Combine Files and the grouped combine registered
incoming form fields under names the destination already used, silently
blurring them into one field (fields of one fully-qualified name are one
field, so their values mix). Importing now walks both sides' fully
qualified names (parent.T.child) — including names that hang only off
page widgets, which /AcroForm never lists — and renames a colliding
field's own /T to the first free variant (Text1-1, Text1-2, …), so its
children keep their place in the tree and the second of two colliding
incoming fields is renamed too.

A field_conflicts policy (rename by default; merge keeps the older
behaviour) and reporting — ImportOutcome::renamed_fields, the combine
outcomes, and a field_name_conflicts(dst, src) pre-scan — let callers
choose and prepare; engine, tool and Combine-tab wiring follow.

* M4: engine and tool wiring for renamed field names

Insert Pages from File carries a field_conflicts policy (rename by
default) and reports what it renamed: a repair-log note names each
old → new pair and warns that scripts referring to the old names were
not rewritten, and the undo step's label counts the renames, so nothing
is slipped in silently. Undo restores the original names, as ever.

The session's combine paths gain reporting variants —
combine_unlocked_reporting, combine_grouped_reporting — returning the
bytes and the renames, and doc_combine takes field_conflicts
("rename" | "merge") and lists renamed_fields [[old, new], …] in its
result when fields were renamed, so agents can find what their scripts
must call the field now.

* M4: the Combine tab warns before combining that field names collide

The Warnings column previews, per file, the form fields whose names
collide with earlier files' and will be renamed (Text1 → Text1-1, …),
computed once per list by a new in-order pre-scan (organize
combine_field_conflicts over the whole list, engine wrapper over the
tab's byte sources; a file that cannot be opened previews as nothing,
as its row already says why). The message is plural-aware and
translated in all sixteen catalogs.

* M4: parity for renamed field names on combine and insert

organize.field-name-conflicts ships: rename by fully-qualified name
(or merge, on request), reported everywhere it happens — repair log
and undo label on insert, doc_combine's result, the Combine tab's
warning column. The notes say what is still missing: the tab's preview
ignores narrowed page ranges, the UI offers no merge choice,
Create ▸ Multiple files renames silently, and scripts and XFA bindings
that reference old names are never rewritten.

* M4: review fixes for the field-name conflict work

Four findings from the PR's code review:

- Clippy: the -D warnings gate failed on the new engine tests (a
  format! with no interpolation, two drop(doc) no-ops on a
  reference); the branch could not pass its own CI.

- The Combine tab's preview warned that a file split into parts
  collides with itself: the preview made one source per row, while
  the combine groups parts of one file and imports it once. The
  preview now dedupes by group (each group's first entry stands for
  the file, as the combine does), and every part shows the file's
  preview.

- Collision detection compared /T bytes, so one name written in two
  encodings (PDFDocEncoding, UTF-16BE) did not collide — the exact
  silent blur this work exists to prevent — and renaming a UTF-16BE
  name rewrote it as from_utf8_lossy mojibake. Names are now compared,
  suffixed and written as decoded text (PdfString::to_text /
  PdfString::text), which is also how the forms crate reads them.

- The engine's combine_unlocked/combine_grouped duplicated their
  _reporting twins line for line; they now delegate with the Rename
  policy, as organize's own wrappers already did.

* Field-name conflicts: only Combine and Insert from File rename

import_pages (Duplicate, Replace) and extract_pages (Extract, Split) go back to Merge, so
copies of a document's own fields keep their names and a source with two same-named field
roots isn't split apart. Pasting pages into the document they were copied from also merges;
pasting into another document renames as Insert from File does. Regression tests for
duplicate, replace, extract/split (organize), duplicate (engine) and paste (ui-egui); the
PR's rename tests call import_pages_with(Rename) explicitly.

Merge fix-up: main made edit labels a translatable Label, so the rename suffix is now two
wordings in EDIT_LABELS ("Insert pages from {name} — 1 field renamed" / "— fields
renamed"), translated in all 20 catalogs. The Combine warning's @plural row is added to
el, kk, sl and tr. Catalog hashes refreshed.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:18:21 -04:00
Bjorneseth 09287e547f
M5.7: Fill & Sign Scan tab for photographed signatures (#1110) (#1111)
Add a Scan tab to Fill & Sign ▸ Add signature. A phone photo of a
signature on paper is framed automatically (all words; table, sheet edge
and dust ignored; a sideways signature turned upright). The frame can be
moved, resized or redrawn, the photo turned in quarter turns, and an Ink
sensitivity slider picks up faint ink. A morphological paper estimate
(van Herk/Gil-Werman closing) removes shadows and uneven light; the
trimmed ink in its own colour on transparency is saved and reused like an
image signature.

- engine: new signature_scan module (SignaturePhoto, PhotoRect, Turn,
  ScanError), bounded decoding, no new dependencies.
- Image tab: accepts full-resolution photos (up to 40 MiB, 50 MP) and
  scales them down to the saved-signature limits instead of refusing them.
- Photos and images can be dropped on the Image and Scan tabs.
- fill_sign_add: `scan` and `sensitivity` with `path`.
- Translations for all catalogs, parity entry, roadmap log.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:16:34 -04:00
lollinoo 7caaa35a81
Fix Word invoice layout and pagination (#773) (#1126)
* M10: keep invoice overlays out of Word body flow (#773)

Export inferred tables without visible borders. Keep text and images
positioned on pages with artwork, rotated or large text, and retain
each source page's size, crop rotation and units.

Cover invoice conversion through the automation tools, and handle
unavailable Windows symlink setup in the confinement test.

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

* M10: add @lollinoo's chosen contributor credit (#773)

The contributor requested their public GitHub profile name while preparing the #773 PR.

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

* Word export: float a page only when text overlaps an image or watermark

positioned() turned every page with any image, or with text twice the body
size, into absolutely positioned VML text boxes, so a page with a logo or a
big title lost its flowing paragraphs, headings and tables. Now a page floats
only when it has rotated text, or when an image or a watermark-sized text
block actually overlaps other text (bounded pairwise test). Other pages flow
with images inline. Adds a regression test for a page whose image and title
sit clear of the text, and updates the docs and parity notes.

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:10:32 -04:00
krinry 4701a00831
Edit text: support Devanagari system font fallback and Indic pre-base matra reordering (#1033)
- UI font fallback: add Devanagari probe (U+0915) and candidate font paths (Noto Sans Devanagari, Lohit, Nirmala, Mangal, Devanagari MT, Kohinoor) in system_fonts.rs, and register SYSTEM_DEVANAGARI_FALLBACK across all family stacks in theme.rs so Hindi text renders clearly in the inline editor and chrome.
- Text editing & extraction: reorder visual-order Indic pre-base vowel signs (e.g. Devanagari 'ि' U+093F) to standard Unicode logical order upon extraction in text_lines and reading_lines, and provide a visual-order fallback during font reuse in replace_line and rewrite_block.
- Add unit tests for Devanagari font fallback coverage in crates/ui-egui/tests/fonts.rs and Indic visual/logical reordering round-trip in crates/edit/src/tests.rs.
- Credit krinry with sync_github_name in contributors/people.toml.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:03:40 -04:00
Márk Bartalos a786c8fb02
Polish chrome hover, press, and selection (#457)
* Ease hover and press feedback on chrome controls.

* Polish chrome alignment, selection, and borders.

* Drop the unused mut on page-thumbnail responses.

The context menu borrows the response immutably, so the hand-cursor binding does not need mut.

* Format the icon button wash

* Test the shared icon button for pointer parity only

Its painting now differs from the legacy path on purpose (eased wash, selected
outline, sinking glyph), so the frozen-shapes comparison no longer applies.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:01:36 -04:00
komo1iddin 6d843a8e8b
Preserve source fonts in text editing and resize clipped artwork (#1116)
* M0: remove upstream trademark artwork from contributor fork

Use the existing document icon and a plain project label. Remove trademark
asset entries and regenerate attribution as required for modified forks.

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

* M7: group compatible PDF words and hide source text during editing

Join compatible words across text objects and subset-font spaces, recognize
component colours, and reuse subset fonts with numeric space positioning.
Render a read-only background with the selected glyphs removed while the
inline editor is open. Keep one frame and source line breaks, and reset
the layout width when switching paragraphs. Expose the same preview through page_render.

Add synthetic extraction, save/reopen, preview and UI regressions. Record
the remaining mixed-style paragraph and installed-font limitations.

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

* M7: preserve rich PDF regions and preview text edits in place

Group mixed source runs into complete visual regions, retain run styles and source encodings, and use validated embedded source faces in the inline editor. Preserve source baselines and show a single live frame as text grows or shrinks, backed by a read-only draft geometry tool.

Validation: strict workspace Clippy, workspace tests, all 30 WASM crates, asset and parity gates, licensed synthetic edit/save/reopen and desktop checks, and an opt-in four-page private-document regression.

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

* M7: keep cropped artwork from blocking text editing

Track page/path clipping separately from full image placement, use visible selection bounds, and prioritize text hit testing over overlapping artwork. Preserve fixed clips during image transformations and expose visible bounds through the headless tools.

Validation: strict workspace Clippy, workspace tests, 30 WASM crates, asset/parity gates, synthetic engine/tool/UI and native desktop checks, both private-document UI regressions, and all-page local edit/save/reopen/undo on a four-page cropped-artwork PDF.

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

* M7: resize cropped images with their clipping paths

Keep the visible frame and image pixels under the same transform. Preserve the
opposite corner and respond to diagonal, horizontal-only and vertical-only
corner drags without jumping from an off-centre grip. Refuse shared clipping
scopes that would move unrelated content.

Add synthetic engine/tool/UI regressions and opt-in private-file coverage
for 80 corner gestures and 10 grow/shrink save/reopen/undo render checks.

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

* M7: complete source font subsets and preserve clipped editing

Add explicit document fonts, CFF embedding, range formatting and bounded contextual shaping; isolate shared image clips and verify private Chinese edits on all pages.

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

* Revert "M0: remove upstream trademark artwork from contributor fork"

This reverts commit 3aa539b5b69e97419bbab83e6cca3d38835b8de8.

* Test: give the text layer up to 4 s before copying (it flaked on a loaded machine)

---------

Co-authored-by: Kamoliddin Fatkhullaev <komoliddinfatxullayev@MacBook-Pro-Komoliddin.local>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:27:55 -04:00
skylar taylor-barrick f3571c591f
Merge pull request #1015 from storytold/codex/craft-print
M10.5: Adopt craft-print spooler with Windows printing fixes
2026-10-11 18:12:40 -06:00
Earl Co 4a1e205f81
Accessibility: export before-and-after review record (#1204)
* Add accessibility before-and-after review record tool

* Parity: leave out the review record (the parity list tracks Acrobat features; this is a PdfCraft extension)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:37:00 -04:00
Earl Co b65b7adb01
Accessibility: edit structure tag properties through tools (#1210)
* Accessibility: edit structure tag properties through tools

* Change tag properties: listed in EDIT_LABELS and translated in every catalog; refresh hashes

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:36:00 -04:00
skylar taylor-barrick 78f7ee0454 M10.5: Keep the adoption current with main progress and viewer fixes 2026-10-12 01:28:15 +02:00
Earl Co fe2eadb211
Accessibility: identify structure objects in checker findings (#1207)
* Accessibility: identify structure objects in checker findings

* Include object generation in accessibility findings
2026-10-11 19:24:30 -04:00
Earl Co 0824f44daf
docs: sync Earl Co contributor name from GitHub (#1199)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:24:13 -04:00
cyfung1031 eb32e3adf7
M2: keep doubled short text runs from interleaving (#1198)
* M2: split short whole-run redraw resets before placement

* M2: reject non-finite redraw prefix positions

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:21:24 -04:00
skylar taylor-barrick 00184a1006 M10.5: Preserve main duplex-driver warnings through craft-print 2026-10-12 01:20:52 +02:00
gyurix f5452cf126
Linux: prefer server DPI over panel physical DPI in vendored winit (#1150)
* fix(x11): prefer server DPI over panel physical DPI in winit

Without Xft DPI, use the X server screen dimensions before the output physical density. Xorg can run a dense panel at 96 DPI; EDID sizes alone make only winit apps appear zoomed.

Supersedes #908: preserves Xft.dpi priority, configured HiDPI screens, and WINIT_X11_SCALE_FACTOR override (including randr). Falls back to RandR output density when server dimensions are missing.

Adds default_dpi_factor() with 4 unit tests plus vendored-copy guard test in apps/pdfcraft/tests/winit_x11_dpi.rs.

* vendor/README: record the winit X11 DPI patch as (11)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:12:32 -04:00
Diego Ospina 492207ab2c
i18n(es): add 84 missing rows, consistent tú register (#1134)
* i18n(es): add 84 missing rows, consistent tú register

Spanish lacked 84 labels that every other full catalog has (About window,
field alignment, redaction marks, certificate details, address warnings…),
so they showed in English. Five strings used formal usted; the field mask
hint was garbled; per-page rotate now matches the other rotate commands.

AI-assisted, native review welcome.

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

* Spanish: one row each for Run form script and Mark differences

---------

Co-authored-by: Diego Ospina <diegozpy@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:09:32 -04:00
Earl Co f94c4a6ffd
Show annotation actions beside selected text (#1133)
* M5: show actions for selected page text

* Selection actions: literal labels (so catalogs can cover them), no unwrap or unreachable

* Catalogs: Add comment in every interface language; refresh hashes

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:04:58 -04:00
Earl Co 377f8da36b
M14: choose Tools panel side in Preferences (#1130)
* M14: choose Tools panel side in Preferences

* Catalogs: Tools panel side in every interface language; refresh hashes

* OCR dialog: the shared outlined checkbox (raw ui.checkbox is linted since #1173)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:54:06 -04:00
Raj Patel 634f3d1144
Issue #1028: OCR can read pictures on pages that already have text (#1184)
* Issue #1028: OCR can read pictures on pages that already have text

Recognize Text skipped every page with text, so a picture of text on a
page that also has real text never became searchable. The engine and
the ocr_recognize tool already had skip_text_pages, but the desktop
dialog never offered it, and turning it off read the page's own text
again and added it a second time, so search found every word twice.

- Engine: always collect the page's own glyph boxes; with
  skip_text_pages false, a recognised word at least half covered by
  them (an earlier OCR layer included) is left out. OcrPage counts
  those words as already_on_page. The default still skips pages with
  text.
- Dialog: "Also recognize pages that already have text", kept with the
  other choices and used by Recognize text in multiple files too.
- Tools: ocr_recognize reports already_on_page; ocr_recognize_files
  accepts skip_text_pages.
- The two new labels in the 15 catalogs that translate the OCR dialog.
- Synthetic tests (a picture of a sentence plus real text) for the
  engine, the tool and the dialog; the engine test fails with the
  filter turned off. Unit tests for the coverage check, including
  NaN, infinite, inverted and overlapping boxes.
- parity, docs/roadmap.md M10 row and the ROADMAP.md progress log.

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

* Issue #1028: Czech labels for the OCR text-pages option

The Czech catalog was completed (#1066) while #1028 was in review, and
czech_covers_ui_literals requires every tl! literal: the two new
Recognize Text labels were missing, so the test failed after the merge.

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

* Contributors: credit rp6186030 as Raj Patel in the About window

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rp6186030 <25706807+rp6186030@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:45:36 -04:00
Dimitar fb97ce958d
M10: Windows two-sided binds on the chosen edge; note drivers that ignore Two-sided (#1154)
The print script mapped "Flip on long edge" to System.Drawing's Duplex.Horizontal and
"short edge" to Vertical. Those are DEVMODE's DMDUP_HORIZONTAL (short-edge binding) and
DMDUP_VERTICAL (long-edge binding), so every Windows two-sided job bound on the other edge:
a PCL driver received @PJL SET BINDING = TOP for long edge. They are swapped back.

Win32_Printer's Capabilities say whether a driver prints two-sided (3, Duplex Printing). The
printer list already comes from Win32_Printer, so it now carries that as Printer::two_sided
(None on CUPS), the printers tool reports it, and the Print dialog notes when the selected
printer's driver doesn't take two-sided settings and points to Properties…. Such drivers keep
their own setting even with Two-sided Off. No vendor-specific names are used.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:41:02 -04:00
ymote 287fce8f9c
M2: bound the total size of page-label prefixes (#1146)
Both page-label readers decode each range's prefix into a string of its
own: render's inspection for its range table, Organize for the ranges it
returns. Ranges may share one prefix object, so a small file could ask
for a copy per range: within the 200,000-entry tree limit, about 200 MiB
of copies of one 1,024-byte prefix. Decoded prefixes now take at most
4 MiB in all (MAX_PREFIX_TOTAL_BYTES, beside MAX_LABEL_TOTAL_BYTES). Past
that the tree is refused before any label is made, as the other tree
limits refuse it.

Tests: 5,000 ranges sharing a 1,000-byte prefix are refused in both
readers (relabelling such a tree changes nothing), and exactly 4 MiB of
prefixes is still read.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:38:48 -04:00
jcl-bit f61a1e5465
M14: "Edit with PdfCraft" on the File Explorer menu of PDF files (#682) (#1180)
* M14: "Edit with PdfCraft" on the File Explorer menu of PDF files (#682)

The Windows MSI adds a classic shell verb under
HKLM\Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit that
runs "pdfcraft.exe" "%1" with the app's icon. It appears whichever app is
the default for .pdf and never changes that default. On Windows 11 it is
under "Show more options": a first-level entry needs an IExplorerCommand
handler and package identity, which an MSI doesn't have.

MultiSelectModel is Document: several selected PDFs (up to 15) start one
launch each, and the existing single-instance hand-off opens them as tabs
of one window.

The verb has its own component, conditioned on a new secure public
property INSTALLPDFCONTEXTMENU (default 1, a welcome-dialog checkbox,
INSTALLPDFCONTEXTMENU=0 for unattended installs), following
INSTALLDESKTOPSHORTCUT. Its menu value is the key path, so uninstall
removes every value. The portable zip still registers nothing.

test-msi.ps1 checks the compiled Registry, Component, Property and
CheckBox rows, and that the .pdf default is never written. Docs: README,
docs/releasing.md, parity notes, ROADMAP log.

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

* Leave ROADMAP.md to main (its log moved on); the change is described in the PR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:35:02 -04:00
Todd Pederson 0ef75d294c
UI: checkboxes and radio buttons are outlined, and checkboxes are square (#1173)
* UI: checkboxes draw a rounded-square box, not a radio-button circle

The theme's 6 px widget radius made egui's 14 px checkbox box a circle,
so View > Page display's Continuous scrolling and Show cover page looked
like two more layout radio buttons. On the light theme an unticked box
had no outline and the panel's fill, so it couldn't be seen.

Every checkbox (81 call sites) now goes through widgets::checkbox /
checkbox_without_text, which draws the box with a 3 px radius and a 1 px
border outline, keeping an outline the caller already set (dialogs).
clippy.toml disallows egui::Ui::checkbox and egui::Checkbox::{new,
without_text} so new checkboxes use the helper too.

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

* UI: radio buttons are outlined and filled, so they show on the light theme

On the light theme the field colour is the panels' white and widgets
have no outline, so an unselected radio button (and an unticked
checkbox) was invisible outside dialogs: View > Page display showed only
the selected dot.

Every radio button (79 call sites) now goes through widgets::radio /
radio_value, sharing the checkbox helper's styling: a 1 px border
outline, text_muted on hover, and on the light theme the plain fill
(hover) as dialogs already had, keeping what the caller set. The dark
theme keeps its fills. clippy.toml also disallows egui::Ui::radio,
egui::Ui::radio_value and egui::RadioButton::new.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:34:16 -04:00
Krzysztof Cieślik 38df401687
M4: replace selected pages from organize toolbar and context menus (#786) (#1149)
Add 'Replace pages…' action to the Organize Pages toolbar, organize grid thumbnail context menu, and Pages panel context menu to replace selected pages.

- Add ViewAction::ReplacePages triggering replace_pages_dialog()
- Expose 'Replace pages…' button with 'replace' icon on the organize toolbar
- Expose 'Replace pages…' on the organize grid and Pages panel thumbnail context menus
- Update parity evidence for organize.replace-pages
- Add integration tests for toolbar and context menu replace actions in crates/ui-egui/tests/editing.rs

Co-authored-by: KrzysztofC <krzysztofc@localhost-live.lan>
2026-10-11 18:32:29 -04:00
Earl Co 036e00f2a1
Test PDF viewer trackpad pinch zoom (#1148) 2026-10-11 18:32:16 -04:00
Mex 9622f29d04
M5: preserve comment identities during partial XFDF and FDF imports (#1144)
Reuse a replaced named comment's object reference so omitted replies and
review statuses remain attached. Keep popup replacement behavior and let
the final duplicate record control its deferred reply relationship.
Resolve indirect names in existing PDF comments so updates match their
original objects instead of creating duplicate parents.

Cover existing-PDF updates, incremental and full saves, subsequent FDF
exchange, page-scoped names, unnamed additions, duplicate records and
indirect names with surviving reply and review-status references.

Co-authored-by: MMexicann <MMexicann@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-11 18:32:03 -04:00
MarekSpodBiedry 75b113ac5b
Raise workspace rust-version to 1.95 to match the UI crate (#1169) (#1188) 2026-10-11 18:30:25 -04:00
cyfung1031 3fa7a3a3e8
M14: keep structural page order through inspection (#1182)
* M14: keep structural page IDs in traversal order

* M14: reuse structural page order during inspection
2026-10-11 18:30:13 -04:00
Joel Tony 931dfa0d7d
Issue #1185: Protect refuses passwords that encryption would make empty or equal (#1186)
Encryption derives its keys from a prepared password, not the typed one:
AES-256 (R6) applies SASLprep, falls back to the password as given if
SASLprep refuses it, and truncates the UTF-8 to 127 bytes; RC4/AES-128
(R2-R4) use PDFDocEncoding cut or padded to exactly 32 bytes, and the
padding starts with "(". Protection::validate checked only the typed
strings, so a permissions password of U+00AD wrote a file that opens with
owner rights and no password, and an open password "A" with a permissions
password U+FF21 (or RC4/AES-128 passwords "a"x31 and "a"x31 + "(") wrote a
file whose open password lifts the restrictions.

pdfcraft-crypt gains effective_password(algorithm, password), the bytes the
keys are derived from (it and create share the private prepared_password
helper), re-exported from pdfcraft-cos. validate refuses a password whose
effective bytes are those of "", and an open and a permissions password
whose effective bytes are equal. Opening files is unchanged.

Fixes #1185.
2026-10-11 18:30:00 -04:00
Ufuk Efe 826941dcfc
docs(contributors): add Ufuk Efe to people.toml (#1177) 2026-10-11 18:29:48 -04:00
ysrdevs 82b709d516
XFA: import and export data files, and pictures for image fields (#1175)
Export Data on an XFA form writes its data as Acrobat does: XML (the
data alone, rooted at the form's root data node, its namespaces
declared) or an XDP holding the whole datasets and a reference to the
PDF (.xdp, a new format). Import Data takes an XDP, a datasets or data element, or a
data document whose root is the form's root data node, and makes it
the form's whole data, keeping data descriptions; a laid-out form is
laid out again from it (rows added and removed) and its calculations
run; XFDF, FDF and tables still go the AcroForm way. Undoable.

Image fields are now buttons marked /PCXfaImage (no PDF action): the
forms layer treats one as an image field, so clicking it (or
form_set_image) picks a picture, which is kept in the data as base64
with its xfa:contentType, as Designer saves it, and the laid-out form
shows it from there.

xfa: replace_data, write_data_image (hostile content types cleaned,
32 MB cap), export_data, import_data (64 MB cap, the parser's node
limit, self-closing data elements). engine: export routing by
document kind, import routing by the file's root element, re-layout
and calculation after the data changes outside the fields, the
picture's content type from its bytes. UI: XML and XDP in the export
dialog of an XFA form, .xdp in the import filter. Tool texts, README
and parity (form.xfa-data) updated.

Hardened after an adversarial review pass: the file's data is held as
a tree and written with the packet's own namespace prefix (never its
markup), every packet rewrite is parsed back before it is written,
files capped at 16 MB and 64 levels (a tag scan before the recursive
parser, also guarding the datasets parser), pictures at 8 MB and PNG/
JPEG/GIF only, the file's shape and root name checked, UTF-16 and
ISO-8859-1 decoded, pictures into data groups refused, cleaned names
for pictures, static forms empty the fields the file doesn't hold,
more rows than the layout cap reported, one datasets stream per edit,
and only image fields take pictures into the data.
2026-10-11 18:29:35 -04:00
Raj Patel 29dca46089
Bound text search result allocation (#1164)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:27:48 -04:00
FredApps 6091deb829
M7: preserve overlapping text when dragging paragraphs (#594) (#1052)
* M7 #594: preserve ordinary paragraph drags (source-only GGEO candidate) [skip ci]

Route body drags through source-preserving MoveObjects, bind gestures to
the document generation, and keep added-content input ownership with
the Edit panel open or closed. Add nine real-shell source regressions.

Source-only transfer to the owner's fork, explicitly authorized before
validation. Independently source-reviewed; formatting, builds, tests,
rendering and all gates remain pending on ggeo-ai02. No upstream PR.

AI-assisted with OpenAI.

* M7 #594: record validated ordinary paragraph drags

Format the source-reviewed candidate and record successful GGEO validation:
focused UI/engine tests, exact-base source loss, native drag/history/save/reopen,
independent Poppler checks, and all eight repository CI gates.

Credit: @FredApps

* M7: use generic validation wording

* Tests: pin the clock where two saves or combines are compared byte for byte

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

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:25:25 -04:00
skylar taylor-barrick c5f1cb2d3f M10.5: Reconcile hosted CI and keep the Arabic golden in both font builds 2026-10-12 00:23:30 +02:00
Tanapat Eiam-arj 8ee3673bd4
M7: support shaped Thai text in Add Text and UI fallback fonts (#767)
* M7: support shaped Thai text in Add Text and UI fallback fonts

Share the bounded shaping and Type 3 writer with Thai, preserving vowel and tone clusters and original Unicode through save/reopen. Keep the Thai face optional and update UI notices and their translations.

Constraint: Thai font assets remain in the companion craft-fonts repository.

Confidence: high

Scope-risk: moderate

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

* M7: show Thai Add Text before and after the fix

Document the rejected input on the base commit and the saved Thai text on the fixed build using genuine UI captures.

Constraint: Synthetic content and licensed fonts only; system font fallback disabled.

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

* M7: demonstrate Thai stress cases in the comparison screenshot

Show nine lines of stacked marks, both Sara Am encodings, Thai digits and mixed Latin text. Verify all 198 Unicode code points survive saving and extraction with both PdfCraft and Poppler.

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

* CI and releases: craft-fonts pin to 3d9ab3a (adds Noto Sans Thai)

ci.yml, freebsd.yml and the craft-fonts-ref release.yml passes to craft-common-actions move
from 8dcdacd to storytold/craft-fonts 3d9ab3a, the revision with Noto Sans Thai (#18), so
Thai Add Text is built in and tested.

* ATTRIBUTION: drop the screenshot entry again (the image isn't committed)

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:07:36 -04:00
Zeyu Li b91d38c073
M14: text the fonts can't draw is kept and reported, never written as ? silently (#1112)
* M14: text the fonts can't draw is kept and reported, never written as ? silently

Form values and captions, text boxes, callouts, Fill & Sign text, measurement labels and stamp by-lines keep text the standard fonts can't draw, draw ? for it, mark the annotation (PCUndrawn) and report the edit (interface notice, tools' warnings); forms set /NeedAppearances (not in PDF/A). Flattening, redaction overlay text, typed and visible signatures and create-from-text refuse instead. Reports escape such characters as [U+XXXX].

* Catalogs: the two new can't-draw messages in Czech, Slovene, Kazakh, Turkish and Greek; refresh hashes

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 17:53:55 -04:00
Maksim Popov d415749556
Heavy pages: cull off-tile images, cache decodes, preview backdrops, calmer queues (#1117)
* Heavy pages: cull off-tile images, cache decodes, preview backdrops, calmer queues

A large CAD sheet (page 24 of the benchmark file: ~5050 pt wide, 2104
images, 9 MB of content) cost a full page interpret per tile: 4.3 s for a
1024 px tile at 1000%, 10.5 s for the whole page, and a storm of renders on
every pan frame. Tiles now cost 0.6 s, the sheet is covered by a handful of
large tiles, and gestures no longer queue work faster than workers finish it.

- Vendored hayro patch (10): skip images whose stream-size box misses the
  canvas before decoding them, so a tile no longer decodes, resamples and
  rasterizes every image on its page. Pixel-identical; the skip regression
  test now covers images too.
- Vendored hayro-interpret patch (23): decoded image pixels shared across
  renders through the interpreter cache, keyed by stream and decode target
  size within a 64 MiB budget.
- Preview renders (hayro patch 11, RenderRequest::preview through to the
  device): backdrops, thumbnails, grid cells and signature-drag backgrounds
  skip images under 16 device px. Tiles, whole pages, print and export always
  draw everything. Previews may differ by those pixels; finals don't.
- Viewer: zoom/pan settle holds apply only to pages whose raster proved slow
  (slow_pages, 500 ms), so fast pages render with no added latency; tile sides
  grow with zoom and page size (giant sheets need ~8 tiles, not ~28), queued
  center-first and keyed by exact coordinates; Edit-text outlines capped.
- vendor/README.md records the new patches with their tests.

Co-Authored-By: Muse Spark <noreply@meta.com>

* Decoded-image cache: key every decode input, survive a poisoned lock

The key was the dictionary's bytes and the target size, so two images with byte-identical
dictionaries (uncompressed, one size) shared pixels, and an image drawn under a transfer
function reused pixels decoded without it. DecodedImageKey now holds the dictionary's place
in the document (its data follows it), its bytes and object, mask/stencil/raster output,
interpolation, the target size and preview or final quality; images under a transfer
function and inline images (named colour spaces, dictionaries in content streams) are not
cached, and an image over the budget is never kept. A poisoned lock decodes uncached
instead of panicking. Soft masks: the per-renderer cache can't carry a preview mask into a
final render (documented). Regression tests for identical dictionaries, preview then final
through one cache, and transfer functions.

---------

Co-authored-by: Muse Spark <noreply@meta.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 17:42:48 -04:00
tzagim e609722b36
M7: Hebrew in Edit text (reading order, writing order, Hebrew glyphs) (#521)
* Hebrew fix

* Tests: the faux-redaction audit gets its own work folder (it shared "audit" with the space audit, so the two raced)

* Arabic golden test: compare bytes only in a build without an Arabic face (CI's Linux job builds with craft-fonts)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 17:41:06 -04:00
Maksim Popov c65f32a078
Content edits on heavy documents: narrow refresh, cached appearances, per-page invalidation (#1122)
* Content edits on heavy documents: narrow refresh, cached appearances, per-page invalidation

Every content edit (drag, resize, retype) on a large document ran a full
synchronous refresh on the UI thread: a whole-file re-inspection plus a
redraw of every missing annotation appearance, then dropped all page
rasters. On a 72 MB / 202-page CAD file with 27k appearance-less
annotations each commit cost ~1.5 s before anything re-rendered. Now ~0.3 s.

- New Scope::Content for edits that touch only page content, resources and
  added content/marks (move/resize/retype/images/added items/marks): the
  page tree, annotations, fields, links, outline and fonts are unchanged,
  so refresh_content rebuilds working bytes, display bytes and renderer and
  validates with a light check_pages (parse plus page count, keeping the
  roll-back guarantee) instead of a full inspect. added/marks are re-read
  only when the edit can change them; undo/redo re-read both.
- AppearanceCache in pdfcraft-annot: built display appearances are reused
  while no annotation edit runs (content edits leave every annotation
  dictionary alone); cleared on comment, structural and full refreshes.
  set_appearance is split into build/apply halves so a hit skips the draw.
- check_pages in pdfcraft-render: light post-edit validation.
- Viewer: single-page content edits invalidate only their page
  (page_changed); other pages keep tiles, text and thumbnails.
- XFA dataset sync now also runs for content edits.

Co-Authored-By: Muse Spark <noreply@meta.com>

* Fonts added by a content edit show in Properties and doc_info (read again on demand); form edits clear cached appearances too

* M14: DATE_LANGUAGES is a static (21 entries trip clippy's large_const_arrays); rustfmt the Greek entry

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

* Preferences scroll in a short window; tests scroll to controls below the fold (#487's roomier rows); the highlighter test waits for its mark under load

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

* Select tool: a multi-click selects only on the frame egui reports it, so a cleared selection (or the highlighter's marked word) isn't selected again

The highlighter test now clicks at a human pace, with frames in between, which is what exposed it.

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

---------

Co-authored-by: Muse Spark <noreply@meta.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:28:30 -04:00
Ufuk Efe 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 <echelon@gmail.com>
2026-10-11 16:34:25 -04:00
skylar taylor-barrick 981a2e57c8 M10.5: Integrate main and adopt the validated craft-print spooler 2026-10-11 22:29:54 +02:00
pechnikov2026-star 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 <pechnikov2026-star@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 16:22:16 -04:00
Γιῶργος (George) 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 <2944375+Camthalion77@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 16:16:40 -04:00
spacekomet 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 <1369225+spacekomet@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 16:09:24 -04:00
skylar taylor-barrick 95434e9115 M10.5: Preserve composed sheets with the shared scaling and duplex API 2026-10-11 22:05:01 +02:00
magdale76 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 <noreply@anthropic.com>

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

---------

Co-authored-by: magdale76 <16279778+magdale76@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 15:59:47 -04:00
Daniel Lošák 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

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

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 15:55:21 -04:00
Mustafa Hadidi 0f7ff1ce30
Improve PDF Navigation with Cursor-Anchored Zoom and Mouse Panning (#504)
* 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 <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:49:39 -04:00
feraremag2680 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 <noreply@anthropic.com>

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

* Camera tests: undo returns a Label now

---------

Co-authored-by: Fernando Areán <soporte@codebit.es>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 15:45:51 -04:00
Márk Bartalos 116fa51262
Give dialogs roomier text fields and padding (#487)
* 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 <echelon@gmail.com>
2026-10-11 15:38:14 -04:00
Dimitar 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 15:33:54 -04:00
Coy Geek 49217aec6c
fix(theme): complete Linux System appearance tracking (#1017)
* 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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:23:49 -04:00
cyfung1031 881682c495
Tests: consolidate safe ui-egui integration targets (#992)
* 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 <echelon@gmail.com>
2026-10-11 15:20:29 -04:00
Ridwan 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 <echelon@gmail.com>
2026-10-11 15:18:01 -04:00
Piohrun d576fe2b06
Forms: keep tiles and render unbaked field values during page re-render (#636)
* 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 <echelon@gmail.com>
2026-10-11 15:15:48 -04:00
mjohnson7cd 0c1528f17d
feat: Scale down large signature images instead of rejecting (#1075) (#1124)
* 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 <mjohnson7cd@users.noreply.github.com>

* A placed image too large to preview gets its own message; scale with thumbnail, which needs far less memory

---------

Co-authored-by: mjohnson7cd <mjohnson7cd@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 15:10:03 -04:00
Jeremy Yao b642e327e7
Security: fix 10 verified audit findings in redaction, signature validation, and untrusted-input handling (#1099)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* Use main's decoded-size ceiling for PNGs too; keep the depth tests on main's checker

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 15:09:39 -04:00
magdale76 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

---------

Co-authored-by: magdale76 <16279778+magdale76@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 14:57:31 -04:00
durtyrobbin 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 <echelon@gmail.com>
2026-10-11 14:49:32 -04:00
Oussama Dalhi a3753c52f9
Fix text selection cursor on all tools toggle(View More - View Less Button) (#1121)
* Fix text selection cursor on all tools toggle

* Apply Rust formatting
2026-10-11 14:46:33 -04:00
Chiranjeevi U 9644aeb9e2
Cap unbounded editing and text caches; index form fields once (#1115)
* 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 <244287281+chiranjeevi-max@users.noreply.github.com>

* 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 <244287281+chiranjeevi-max@users.noreply.github.com>

---------

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
2026-10-11 14:46:19 -04:00
Nicholas Yoannou 92c9de7aad
Comments: line breaks saved as CR show in comment text (#1113)
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).
2026-10-11 14:46:05 -04:00
jcl-bit 0e8c978c6a
M10: Windows printing starts PowerShell by its full path (#1106)
`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 <noreply@anthropic.com>
2026-10-11 14:45:52 -04:00
gyurix 9564ed6885
Render visible pages before thumbnails and text extraction (#950)
* 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 <gyurix@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 14:45:36 -04:00
MarekSpodBiedry 8983f05c78
On-page text boxes keep a dark caret on their white backgrounds in the dark theme (#655) (#734)
* 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 <echelon@gmail.com>
2026-10-11 14:42:14 -04:00
Dylan M. Taylor 2ec7102035
chore: bump github actions to fix Node 20 deprecation warnings (#641)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 14:39:33 -04:00
Nigel Ihza Firdausy 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 <echelon@gmail.com>
2026-10-11 14:36:41 -04:00
ejbarraza 21d5323132
Add faux-redaction audit (paint-order and colour-aware, fail-closed) (#110)
* 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 <ejbarraza1994@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 14:32:33 -04:00
Nigel Ihza Firdausy 0059ed657c
fix(annot): validate finite link areas before add or edit (#940)
* 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 <echelon@gmail.com>
2026-10-11 14:31:59 -04:00
pauliusiv123-rgb 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 <echelon@gmail.com>
2026-10-11 14:31:32 -04:00
Lucas e4c9cc1a19
Open dialog: allow selecting several files at once (#1086)
* 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 <echelon@gmail.com>
2026-10-11 14:28:42 -04:00
cyfung1031 42cfc6a8f2
M14: scan PdfCraft page-content markers once per document refresh (#1016)
* 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 <noreply@openai.com>

* 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 <noreply@openai.com>

* 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 <noreply@openai.com>

* 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 <noreply@openai.com>

* 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 <noreply@openai.com>

* marks_present and list_added read through scan_markers, so the parsing lives in one place

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 14:08:27 -04:00
krinry b90875466e
Select tool: trigger multi-click text selection on pointer press (#1024) (#1040)
* 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 <echelon@gmail.com>
2026-10-11 14:05:12 -04:00
cyfung1031 575f99f9be
Add an opt-in low-disk Cargo profile (#951)
* PR-02: add opt-in low-disk Cargo profile

* PR-02: document low-disk Cargo profile

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 14:02:06 -04:00
lukashold 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 <noreply@openai.com>

* 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 <noreply@openai.com>

* M4: translate page selection and local actions

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

* M4: cover overview entry, selection and page actions

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

* M4: record the organizer workflow improvement

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

* M4: link page overview regression evidence

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:58:58 -04:00
Justin John 1651069e7a
Viewer: Continuous scrolling is an independent checkbox over the base layout (#752) (#896)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* Parity: the continuous-scrolling feature points at view.continuous

---------

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:54:26 -04:00
Joel Tony 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 <echelon@gmail.com>
2026-10-11 13:51:10 -04:00
Justin Wilkins 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 <echelon@gmail.com>
2026-10-11 13:47:51 -04:00
Wladmir Júnior 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 <echelon@gmail.com>
2026-10-11 13:44:40 -04:00
cyfung1031 473d5d48d4
M2: overlapping text runs stay whole; placement in arrange, bounded checks (#748)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:41:15 -04:00
pkibiego adad3aae2c
xfa, xfdf: refuse XML nested past 1024 levels instead of aborting the process (#987)
* 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 <echelon@gmail.com>
2026-10-11 13:38:10 -04:00
pechnikov2026-star 6ed0e66848
render: don't let a hostile page-tree /Count abort inspection (#956)
* 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 <pechnikov2026-star@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:33:02 -04:00
Omar Elders bed9a48ccd
fix(ui): preserve scroll position when collapsing or resizing sidebars (#1056) (#1069)
* fix(ui): preserve scroll position when collapsing or resizing sidebars (#1056)

* Run cargo fmt

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:32:38 -04:00
盈秋 6f96c40eaa
Chinese UI: draw with an installed Chinese face when the build has none (#826) (#925)
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) <noreply@anthropic.com>
2026-10-11 13:32:14 -04:00
art804 d47c615a4c
Windows: Save asks where to keep an Outlook attachment instead of writing its temporary copy (#601)
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.
2026-10-11 13:32:00 -04:00
cyfung1031 f08d6b17ab
M12: add deterministic snap property tests to the measure crate (#714)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-11 13:31:46 -04:00
PurpleDaddy 12346180c4
docs: register the MCP server from an installed release (#643)
* docs: register the MCP server from an installed release

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

* docs: drop the third-party link; the Windows path is the default install folder

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

* docs: drop the third-party link; the Windows path is the default install folder

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

* docs: drop the third-party link; the Windows path is the default install folder

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:31:32 -04:00
cyfung1031 dce9b9f912
M2: keep non-interpolated minified images sharp (#635)
* 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 <noreply@openai.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:31:18 -04:00
mjohnson7cd dda1a155fd
docs(readme): fetch the OCR models in Get started (refs storytold/artcraft#2104) (#1006) 2026-10-11 13:28:42 -04:00
mitul72 657173fac1
M14: a chosen interface size is absolute, as in PhotoCraft (#1005)
"100%" in Preferences ▸ Interface size multiplied the scale the display
reports, so on a 1080p screen whose system reports 200 % it still drew at
200 % (#456). A chosen size is now absolute, as PhotoCraft's UI Scale is: the
display's scale is divided back out, so "100%" is one screen pixel per point.

Auto is unchanged (the display's scale times GNOME's text scaling), and its
menu entry now shows the size it draws at. The menu offers PhotoCraft's sizes,
75–300 %.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:28:30 -04:00
DWAA1660 4582f48ec1
automation: text_find stops reading pages once its limit is reached (#971) (#990)
text_find extracted (and cached) the text of every page before looking
for the first match, so `limit: 1` on a 9,000-page manual meant a whole
document extraction. It now reads pages in batches (8, doubling to 128)
and stops as soon as it has one match past the limit. The result says
`complete`, and a capped result carries `next` ({start_page, skip}),
which the new `start_page` and `skip` arguments accept to continue.

Fixes items 1 and 3 of #971 (and item 4 for pages after the limit).

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:24:58 -04:00
cyfung1031 be94112c8e
M11: strip redundant ASCII stream wrappers during optimization (#989)
* cos: decode only the first filter of a stream, strictly

Stream::decoded_first_filter_strict decodes the outer filter of a chain and leaves the rest of the chain encoded, within a caller bound. The optimizer uses it to remove an ASCII85 or ASCIIHex wrapper without inflating the inner data.

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

* optimize: strip redundant ASCII wrappers from filtered streams

A stream whose filter array starts with ASCII85Decode or ASCIIHexDecode loses that outer layer when the serialized replacement is smaller. The inner encoded bytes are kept exactly, and the decoded stream is unchanged. Streams with DecodeParms, image, object stream, or Crypt filters are left alone, as are streams whose wrapper fails strict decoding.

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

* optimize: document redundant ASCII-wrapper cleanup

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

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-11 13:21:20 -04:00
cyfung1031 d818f72a57
M10: stream Create Multiple sources without a fixed file-count cutoff (#978)
Create Multiple (UI and automation, combine mode) and the page-grid drop refused or
truncated selections above MAX_CREATE_FILES (1,000). The cap was the only bound on how
many sources were held at once, and the combine still held every source together: all
picked files were read, converted and opened before anything was combined.

organize::Combiner adds one source at a time and keeps only what the rest of the combine
needs: the source's bookmarks, its attachments, and its document only when it declares
output intents. Session::combine_each drives it from a lazy iterator, so a source is read,
copied and released before the next one is read. The desktop picker and the grid drop read
files as their turn comes. The cap is removed from the engine, the automation schema and the
UI. Output is byte-identical for ordinary inputs.

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-11 13:20:58 -04:00
sfv9 9960e31b72
M9: sign with CAC/PIV smart cards through the macOS Keychain (#968)
Token identities that macOS exposes through CryptoTokenKit (a CAC or PIV
card in a reader) were not listed: the Keychain search only covered the
file-based keychains. keychain::identities now also searches the token
access group, de-duplicates by certificate, and skips card certificates
whose key usage allows neither digitalSignature nor nonRepudiation (the
CAC encryption certificate, which would produce a signature that fails
validation). Card identities are labelled "(smart card)" in the sign
dialog. The key never leaves the card; macOS asks for the PIN.


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

Co-authored-by: sfv9 <286824270+sfv9@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:20:44 -04:00
cyfung1031 0af2fdad05
M7: shard Arabic generated glyph fonts instead of rejecting large text (#967)
An added Arabic item was rejected once its distinct glyphs exceeded
GLYPHS_PER_FONT * MAX_ARABIC_FONTS (3,840). The glyphs already split
into 240-glyph Type 3 fonts, so that item-wide count was an allocation
policy, not a limit of the format. Remove it: an item now gets one font
per 240 distinct glyphs it needs.

Per-font limits, the outline reads that happen before any object is
added, and the output below the old limit are unchanged. The drawing
core takes its outline source as a parameter, so the tests can run
without the craft-fonts Arabic face.

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-11 13:20:30 -04:00
DWAA1660 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 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:20:11 -04:00
cyfung1031 fb3fa5de57
M7: shard Japanese Type 3 fallback fonts across large edits (#958)
A Japanese replacement with more than 240 unique characters was refused. One
generated Type 3 font holds at most 240 glyphs, so the text is now split into
several fonts, each within that bound. The font changes only where the text
moves to another font, and a single font gets the same operators as before.

The reader joins runs across the subset fonts of one face, only for fonts
written by this code (Type 3, /Name PCJapanese). Other documents are unchanged.

A paragraph whose move or width is refused no longer writes its fallback fonts
before the refusal, so the document is left as it was.

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-11 13:16:25 -04:00
pechnikov2026-star 66edead348
demo-pdf: two fixes so xtask demo-pdf and xtask screenshots work on Windows (#954)
* demo-pdf: build a working file:// URL on Windows

Windows Path::canonicalize returns a verbatim \?\C:\... path. After the
backslash swap that is //?/C:/..., which the Unix/Windows branch then
misreads as an absolute Unix path, producing file:////?/C:/... Chrome
fails to load that URL and prints one blank page, so on Windows
cargo xtask demo-pdf and cargo xtask screenshots always fail with
"Chrome produced 1 pages but the showcase layout expects 12".

Strip the //?/ prefix before classifying, and add a regression test.

* demo-pdf: don't accept Skia's incremental %%EOF writes as a finished PDF

Chrome writes the print-to-pdf output incrementally and every increment
ends with its own %%EOF, so the completion poll accepted the first
~29 KB increment (one page of twelve) as final and killed Chrome
mid-print. Require the file size to stay quiet for 10 consecutive
500 ms polls before treating the file as finished; waiting for process
exit alone is not enough because some Chrome builds linger after
writing.

---------

Co-authored-by: pechnikov2026-star <pechnikov2026-star@users.noreply.github.com>
2026-10-11 13:16:12 -04:00
Nigel Ihza Firdausy 43d16ef315
fix(measure): include closing edges of every filled PDF subpath (#952)
* fix(measure): include closing edges of every filled subpath

* fix(measure): include closing edges of every filled subpath

* Run cargo fmt

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:16:00 -04:00
pauliusiv123-rgb a4742ba911
Issue #948: the root-confinement test tries the symbolic-link escape only when the link was made (#949) 2026-10-11 13:15:39 -04:00
DWAA1660 faea6d69b4
Read mode: the rail's Bookmarks and other pane buttons open their panels (#932) (#947)
* Read mode: the rail's Bookmarks and other pane buttons open their panels (#932)

Read mode kept the right rail but never drew the right panel, so its
buttons did nothing. Read mode now hides the tools, not the navigation
panes: the panels open from the rail as in the other workspaces. Entering
Read mode closes the open pane and leaving it reopens it, and a document
opened into Read mode starts with the panes closed unless its Initial View
asks for one (/PageMode /UseOutlines).

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

* M7: a pane closed in Read mode stays closed when leaving it (#932)

Review on #947: opening or closing a pane in Read mode now discards the
pane saved on the way in, so leaving Read mode no longer reopens a pane
the user closed there (and Comments keeps its close preference).

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

---------

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:15:26 -04:00
neronek22 5d7700ead6
Fix plural forms for recent document page counts (#1065)
Co-authored-by: neronek22 <aantoszek@gmail.com>
2026-10-11 13:15:00 -04:00
Ridwan d9660d4e73
Docs: sync the progress docs with issues closed on 2026-10-10 (#1064)
The parity and roadmap docs were last refreshed before a batch of fixes
merged, so they still described closed issues as open problems.

- #774 (encrypted output rejected by Acrobat, fixed by #836): gap 1
  removed from gaps.md; its remaining interop-suite work moved to gap 12.
  Gap numbers are kept so existing "gap N" references stay valid.
- #756 (Windows printing, #847): Printers row, Print area and roadmap
  rank 1 now name web printing as the remaining gap.
- #740 column select (#765), #759 1:1 touchpad scrolling (#868),
  #810 Shift-Cmd-G (#835), #825 (#822).
- #844 multi-object move (#907): marked partial, same page only.
- #799, #800, #807, #809, #811, #815-#819, #821 (incl. the #816 panic):
  gap 6 and the area tables list them as fixed.

Scores, readiness percentages and the stage are not re-judged; only the
rank-0 estimate drops by gap 1's 8-15 h (40-70 -> 32-55).

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:14:48 -04:00
Luniiya 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 <echelon@gmail.com>
2026-10-11 13:12:47 -04:00
mlkonrad 7f1b632519
M2, #1060: axial and radial shadings draw as native gradients (backport of hayro 0.8) (#1061)
InDesign writes fades as luminosity soft masks whose group is one
extended axial `sh` over the whole page. hayro 0.7 sampled every such
shading pixel by pixel through the shading function, so a report page
with 40 of them took 10-15 s at 144 dpi.

Backport hayro 0.8.0's `gradient` module: an axial or radial shading
that a native gradient draws exactly becomes sRGB stops (within 0.01
per channel, a stop at every stitching bound), painted as a vello_cpu
gradient. Upstream's half-pixel shift is left out: it suits vello_cpu
0.3 but moves 0.0.8's gradients off pixel centres.

Page 4 of the reported file: 10.8 s -> 1.0 s.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:12:16 -04:00
sanyamsingh001 f5cfb33773
M6: keep field Properties color palettes independent (#1054)
Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
2026-10-11 13:12:04 -04:00
Tristan von Kries 24cb6018b6
macOS: the reopen-last-session preference starts from NSQuitAlwaysKeepsWindows (#871) (#1053)
System Settings > Desktop & Dock > "Close windows when quitting an application"
is the system's own version of Preferences > "Reopen the files that were open
when PdfCraft last closed". Read it once at launch (defaults read -g, 500 ms
guard, as text_scale.rs does for gsettings) and use it as the preference's
starting value, before the saved preferences are restored. A choice made in
Preferences is persisted and still wins; other systems keep starting off.

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 13:11:51 -04:00
Nigel Ihza Firdausy 96538312df
Fix/noissue hyphenated print label ranges s20261010 t220019 z f82d4 (#942)
* fix(print): resolve ranges between hyphenated page labels

* test(print): resolve ambiguous hyphenated label boundaries
2026-10-11 13:06:56 -04:00
Nigel Ihza Firdausy dd400d6b23
fix(annot): create mailto links for bare email addresses (#941)
* fix(annot): recognize bare email addresses when creating links

* Run cargo fmt

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:06:44 -04:00
snipeTR 0344ed44a0
fix(ui): bookmark, comment and command filters match Turkish İ/ı in any case (#937)
The bookmark filter, the Comments panel filter and the command palette
lowercased with str::to_lowercase, which follows no locale: "İSTANBUL"
became "i̇stanbul" (a combining dot added) and "ı" has no case pair, so
"istanbul" never found the bookmark "İSTANBUL ŞUBESİ" and "imzalayın" never
found a comment "Lütfen İMZALAYIN".

They now fold with pdfcraft_render::text::fold_case, the folding Find and
Search & Redact use (made public here): I, i, İ and ı count as one letter
and every other character folds to its own lowercase.
2026-10-11 13:06:22 -04:00
Hal Goff 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:05:53 -04:00
Tristan von Kries 10f801e752
macOS: the tab strip is the 28 pt band the traffic lights are centred in (#789) (#1055)
AppKit centres the window buttons in its standard 28 pt title-bar band and
nothing in winit/eframe moves them (AppKit calls would need unsafe, which the
workspace forbids). The integrated title bar was 38 pt with 30 pt tabs, so the
lights sat 5 pt above its centre. On macOS the strip is now 28 pt, tabs 26 pt,
icon buttons 24 pt, and content starts after the lights plus the gap they keep
from the top (MAC_CONTENT_LEFT), as storytold/wordcraft#255 did for WordCraft.
Other platforms are unchanged. Unit test for the constants, macOS kittest for
the strip's contents staying inside the band.

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 12:54:47 -04:00
Luis Araya f22b5d424b
feat(macos): declare document icon file for PDFs in Info.plist (#910) (#933) 2026-10-11 12:32:29 -04:00
Nigel Ihza Firdausy 77241397d6
fix(xfdf): combine imported annotation flags with bitwise OR (#931)
* fix(xfdf): combine annotation flags as a bitmask

* Run cargo fmt

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:32:17 -04:00
Nigel Ihza Firdausy 88869196be
fix(macos): close the empty main window with Command-W (#930)
* fix(macos): close empty main window with Command-W (#869)

* Run cargo fmt

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:31:56 -04:00
cyfung1031 70bbb1fbdc
M9: stream structure-tree checks without a total-element cutoff (#926)
* M9: let the structure reader take an element limit for tests

Tree::read keeps MAX_ELEMENTS; Tree::read_with_limit takes the limit, so a unit test can reach the cut-off with a few elements. The characterisation test records today's behaviour: a figure past the limit is not checked.

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

* M9: stream structure-tree checks without a total-element cutoff

The structure checks built the whole tree as a Vec of elements, and stopped adding elements at 2,000,000, so later elements were never checked. One walk now reads the tree with an explicit stack and reports each element as it opens and closes. Each rule keeps only what it needs about the open elements and, for tables, the row spans that reach into later rows. Findings stay in document order, and the report still shows the first 200 per rule.

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

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-11 12:31:35 -04:00
magdale76 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: magdale76 <16279778+magdale76@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:31:23 -04:00
MaxMan 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 <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:27:39 -04:00
DWAA1660 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 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:23:55 -04:00
cyfung1031 db26eb266d
M14: Reuse form enumeration during document open (#1011)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:21:02 -04:00
Michelle 3b7a01eac5
UI: a tool chosen with no document open asks for a file and runs on it (#1100)
Clicking a tool in the tool panel or palette that needs a document, with
none open, used to show only "Open a document first". It now shows the
open picker (titled with the tool's name), opens the chosen file and runs
the tool on it. Encrypted files run the tool once the password is in;
cancelling runs nothing. Menus and shortcuts keep the old message. The web
build opens the file only.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 12:17:33 -04:00
EULA 2912b21d33
XFA: single-block forms paginate, open in their saved state, and show their signatures (#1098)
* XFA: forms in one wrapped block lay out across pages, with their rows and growing fields

A dynamic form whose body is a single block that can't be flowed (left-to-right
wrapped, positioned or fixed-height) laid out to one page, the rest drawn past
the bottom edge with no warning.

- A block taller than a content area is spread over the following ones. Breaks
  fall between fields, draws and lines of text; fills and lines are cut at the
  break; a field or draw moves whole, border and all.
- Repeating subforms in a left-to-right wrapped container get the instances the
  data holds, as in the other layouts (only the first was drawn).
- A multi-line text field without a fixed height grows to hold its text. Past
  the height of a content area it is drawn as text that continues across pages
  (not editable; reported as a warning) instead of one widget cut off.
- Text too long for the fixed height it was given is drawn smaller, down to
  70%, rather than over what lies below (the standard faces are wider than the
  template's).
- Nested measurements carry their SOM path, so instance counts and field values
  are found below the first level.

Synthetic tests cover each. Run: fmt, clippy and tests for pdfcraft-xfa, and
the pdfcraft-engine tests; the workspace-wide gates were not run.

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

* XFA: a form opens in the state it was saved in, and wrapped sections are as tall as their content

The form packet's saved presence, access and captions are applied to the
template, so rows a script showed before saving appear, locked fields stay
locked and a button keeps the label it was saved with.

A flowed subform no longer stretches to a leftover minH: a public form whose
own form packet records 3 pages laid out as 4 and now lays out as 3.

Also: a text field without a border of its own has no box, and a CR LF pair
is one line break.

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

* XFA: a signed signature box shows the signature the file holds

A laid-out signature box takes the value and the appearance of the signed
field of the same SOM name that the signing application left in the
AcroForm, and is read-only. The engine no longer redraws a signed box as an
empty one, on open or on a script's re-layout.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:17:21 -04:00
cyfung1031 dd046a25b2
M2: stream page content past the 256 MiB cap (item 1 of storytold/pdfcraft#623) (#797)
* M2: stream page content past the 256 MiB cap (item 1 of storytold/pdfcraft#623)

Page content was decoded whole, and the decode stopped at
MAX_DECODED_STREAM (256 MiB), so the last ~10 MB of the #623 sample
was dropped without a warning. Top-level page content is now read a
window at a time, so the decoded content is never all in memory:

- the content tokenizer reads windows and holds back an instruction
  that runs past one; Reader records reads that touch the end of the
  data, including reads made through clones, so an instruction is
  only decided once it has been seen whole;
- the Flate decoder is resumable and keeps its own 32 KiB history, so
  it pauses at any output size and back-references do not depend on
  what the caller has kept;
- page content may decode to max(256 MiB, 64 x its compressed size),
  counting each distinct stream once; content that reaches that
  budget is cut and the page reports ContentTruncated, as it did not
  before;
- forms, tiling patterns, Type 3 glyph procedures and generic streams
  keep their existing budgets and caps.

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

* M2: Avoid duplicate page content raw reads

* M2: Document page content byte reuse

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-11 12:17:09 -04:00
MarekSpodBiedry 1c18510b4a
Edit images: moving or resizing a clipped image moves its own clip too (#1022) (#1049) 2026-10-11 12:16:57 -04:00
piero-83 afb586e249
CLI: reject malformed --root options before dispatch (#1048)
* fix(cli): reject malformed root options before dispatch

* test(cli): reject missing empty and repeated roots

* docs: explain strict root arguments and validation

* docs: log malformed root argument regression

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:12:23 -04:00
piero-83 ee1ee70463
Forms: keep the accepted value when Tab wraps to the same field (#1045)
* fix(forms): refresh the editor after accepted Tab edits

* test(forms): keep values through same-field Tab and Save

* docs: record accepted-value Tab and Save behavior

* docs: log verified Tab save regression

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:10:23 -04:00
piero-83 f117cde040
Forms: encode appearances with the existing simple font (#1044)
* fix(forms): encode appearances with the chosen font

* docs: record form font encoding limits

* docs: log verified form encoding regressions

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:07:47 -04:00
aAffliu f1e3a04587
Return renderer initialization errors to the OpenGL startup fallback (#1042)
* M13: report unavailable symbolic-link fixtures in hostile-argument tests

Only include the planted link escape when symlink creation succeeds. On
non-elevated Windows the fixture can fail with OS error 1314; exercising
its nonexistent path otherwise produces false confinement leaks. Keep
all remaining traversal and absolute-path cases and report the skip.

Co-authored-by: Codex (GPT-6) <noreply@openai.com>

* M3: return renderer initialization errors to the OpenGL startup fallback

Guard renderer creation with the existing scoped GPU-error handler and
return CreateRenderer instead of invoking wgpu's default panic handler.
This lets Auto startup reach its existing OpenGL retry before app creation.

Add a real-device invalid/valid pipeline regression and Windows startup
reproduction notes. The host-specific DX12 shader compiler error itself
is not fixed; explicit renderer choices and the non-winit path are unchanged.

Co-authored-by: Codex (GPT-6) <noreply@openai.com>

* Leave the symlink-test fix to #949, which covers the same ground

---------

Co-authored-by: Codex (GPT-6) <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:05:45 -04:00
mlkonrad 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:04:10 -04:00
Atos Lins ea1df6e1cb
Linux: StartupWMClass is the window's app id (#1087)
* Linux: StartupWMClass is the window's app id

The desktop entry said StartupWMClass=pdfcraft, but the window carries the
app id ai.storyteller.pdfcraft (APP_ID) on Wayland and under X11 alike, so
nothing that matches a window to its launcher by that key ever matched it:
on X11 the launch's startup notification can't end when the window maps.
VectorCraft, GridCraft and WordCraft already use the app id.

A test reads the entry and compares it with APP_ID.

* Contributors: add atoslins (Atos Lins)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:02:18 -04:00
Shukat Ibrahim 2fbce7028c
doc_create from text refuses characters Helvetica can't draw (#959) (#1030)
doc_create with from=text wrote every character outside WinAnsiEncoding
(Korean, Chinese, Japanese, emoji) as `?` with no error. It now refuses
such text with the same message page_add_text already gives, naming the
first undrawable character and its code point. Line breaks, page breaks
and tabs are layout and still pass. create_from_text itself is unchanged,
so opening .txt files, comment summaries and compare reports behave as
before.
2026-10-11 11:59:43 -04:00
Atos Lins 86e74ec121
Linux: draw on the GPU the Wayland compositor draws with (#1088)
On a hybrid laptop whose Wayland compositor runs on the discrete GPU
(Hyprland with AQ_DRM_DEVICES naming the NVIDIA card first), the built-in
panel still hangs off the integrated GPU, so the panel rule (#8) picked it,
and the window stayed empty: the compositor took the frames and showed
nothing, with no error anywhere.

Which GPU drives a display stands in for which GPU the compositor draws
with. Under Wayland the compositor says so itself: the main_device of its
linux-dmabuf feedback (version 4), the device it imports client buffers on,
which Mesa's Vulkan device-select layer reads for the same purpose.
compositor_gpu.rs asks for it on a short-lived connection, maps the dev_t
to PCI ids through /sys/dev/char, and pick_adapter ranks that adapter
first. No answer (X11, an older compositor, a GPU no adapter matches)
leaves the existing rules in charge, and WGPU_POWER_PREF still overrides.

wayland-client and wayland-protocols are already in the tree through winit;
they become direct Linux-only dependencies.
2026-10-11 11:59:29 -04:00
Bryan Mark a7e24b9512
M12: XFA script time limits start when the engine is ready (#1081)
* M12: XFA script time limits start when the engine is ready

An XFA script's wall-clock limit (1 s for scripts that run unasked, such
as initialize and calculate) also counted the engine's start-up: the
script thread, a new boa Context, and for the first script in a process
the engine's own one-time set-up (~60-170 ms when idle, shared by every
script starting at the same moment). On a busy machine a trivial
initialize script could pass 1 s, be abandoned, and so turn all of the
form's scripts off, with the error only in the JavaScript console.

That made two ui-egui xfa.rs tests flaky
(xfa_buttons_run_their_scripts_in_the_app,
messages_from_scripts_run_on_open_show_right_away): under full CPU load
25 of 40 runs of the test binary failed, and none of 40 with this
change.

- run_within: the script calls `started` once the engine is ready, and
  the time limit runs from then. Start-up has a cap of its own (10 s),
  so a caller still never waits forever.
- run_here: a once-per-process warm-up script takes the engine's first
  use, so the first form script's timed part drops from ~52 ms to under
  ~11 ms.
- FormCalc (no engine to start) signals at once, as before.
- Tests: a slow start doesn't abandon a script; the script's own time,
  and start-up, are still capped. The UI tests print the script console
  when they fail.

Runaway scripts are still abandoned at the same limits
(runaway_xfa_calculations_at_open_end_quickly_with_a_report).

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

* Add arcanii to the contributor names

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 11:58:28 -04:00
Alexis 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 <echelon@gmail.com>
2026-10-11 11:56:06 -04:00
pauliusiv123-rgb 4666e74691
Text extraction: word gaps follow position on the line, not drawing order (#1062) (#1074)
A line's glyphs are sorted left to right, but the space for a word gap was put before whichever of the two glyphs came later in the content stream. When the right-hand run was drawn first, the space landed inside the left run: 20.0 19.4 came out as "20. 019.4". The same rule put the space one letter early between right-to-left words drawn left to right.

Gaps are now marked by position on the line and reversed together with right-to-left runs.
2026-10-11 11:53:16 -04:00
Dylan M. Taylor 2316a8c419
ci: refactor workflows to use storytold/craft-common-actions (#1073)
* ci: refactor workflows to use reusable artcraft-common-actions

* ci: point reusable workflows to storytold/craft-common-actions

* ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly

* ci: use @main (unpinned) for craft-common-actions with explanation comment
2026-10-11 11:51:25 -04:00
skylar taylor-barrick 6614b370b1 M10.5: Preserve current main printing workers during spooler extraction 2026-10-11 07:36:51 +02:00
skylar taylor-barrick c20f3aff60 M10.5: Adopt shared craft-print system spooler 2026-10-11 07:29:54 +02:00
Brandon Thomas 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 <noreply@anthropic.com>

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:15:23 -04:00
Mawfyy 33c14e07cf
Print: Windows lists its printers and spools jobs (#847)
* Print: Windows lists its printers and spools jobs (#756)

The Print dialog's printer list comes from Win32_Printer through PowerShell's
CIM (wmic is gone from Windows 11), and jobs are spooled by the embedded
windows_print.ps1: the in-box Windows.Data.Pdf renders each sheet at the
printer's own resolution and System.Drawing.Printing carries copies,
collation, duplex and colour as the job's settings. Both run through
powershell, so the Rust code touches no Win32 API. Fixes #756, with the
printer-not-showing duplicates #160, #244, #293, #465 and #644; CIM approach
from #452. On CI a paused Generic / Text Only test printer catches the job in
its queue; a parser test keeps the script valid PowerShell.

* Print: fix the Windows await helper and render the job on CI (dry run)

Real-machine testing found [System.WindowsRuntimeSystemExtensions]::GetMethods()
failing with 'does not contain a method named GetMethods': GetMethods is an
instance method of [Type], so it is called with '.', the form the canonical
WinRT-await snippets use, not '::'. The first CI run missed it because the
end-to-end test skipped silently when the runner refused the test printer, so
the script had never actually run.

The script now honours PDFCRAFT_PRINT_DRYRUN=1: render every sheet with
Windows.Data.Pdf to PNGs in the temporary folder and print nothing. The test
runs that half unconditionally, so CI exercises the whole WinRT path —
projection, await helpers, render — on any machine, while the paused-printer
half stays best-effort and prints why it skipped. Win32_Printer enumeration
asks only for Name and Default, so a machine with nine printers waits less.

* Tests: the find-options wait in the viewer test is a deadline too

4280f83 converted the frame-count waits in this test to deadlines after the
same 12-of-15 race under a full workspace test run, but left the first wait —
the one for the 'page' matches — on the old 60-frame loop, and it flaked the
same way on PR #847's CI. Same fix: wait up to 20 s for the 15 matches, then
assert.
2026-10-11 00:23:35 -04:00
skylar taylor-barrick ae4f5d44f3
Merge pull request #966 from skylartaylor/codex/ui-suite-integration-20261010
Adopt shared panel docking with document-aware visibility
2026-10-10 20:03:37 -06:00
skylar taylor-barrick ecd9d158ea UI: preserve shared document workspace on current main 2026-10-10 19:10:14 -06:00
Brandon Thomas 53a5880386
Bump to 0.6.0; add a Linux riscv64 release build (#945)
- Cargo.toml: [workspace.package] and the internal path-dependency requirements move to 0.6.0
  (they pinned 0.5.0, which `cargo update -w` can't match); Cargo.lock refreshed.
- release.yml: new `linux-riscv64` job, ported from GridCraft. Cross-compiles on ubuntu-24.04
  against ports.ubuntu.com riscv64 libs (the linux job's -dev packages, :riscv64), builds the
  tar.gz and the CLI-only tar.gz, smoke-tests pdfcraft-cli under QEMU, and is in the draft
  release job's needs, so SHA256SUMS covers it.
- packaging/linux/package.sh: CROSS_ARCH / CROSS_TARGET / CROSS_COMPILE / EMULATOR support.
- README and docs/releasing.md list the riscv64 tarballs (glibc 2.39+).
- contributors.json refreshed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:50:57 -04:00
skylar taylor-barrick 8ff27dc86e UI.9: preserve PDFCraft panels and document lifecycle 2026-10-10 15:46:23 -06:00
LloydNicholson b8ddf71e30
Drawn signatures and drawings resize from their handles (#920)
A drawn Fill & Sign signature is an Ink annotation, and Ink couldn't be
resized: the Select tool showed no handles and the engine refused
ResizeAnnotation ("Ink comments can't be resized"). Typed and image
signatures (stamps) already resize (#335, #355), so drawn ones were the
gap left in #331.

- annot: set_rect scales an Ink annotation's strokes into the new
  rectangle, keeping each side's margin between the strokes and /Rect
  so the line isn't clipped, and regenerates the appearance. A straight
  stroke with no extent on one axis is centred on it.
- ui-egui: Ink is resizable with the 8 handles. A drawn signature's
  corners keep its shape, as image signatures' do; other drawings resize
  freely like shapes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 17:42:20 -04:00
LloydNicholson 10965fbf5e
Select tool picks up a signature placed over a form field (#916) (#917)
With the Select tool, a press on a form field went to the field before
comments, so a signature (or any movable comment) sitting on top of a
field could not be dragged: the cursor showed Move, but the field took
the press on the frame the drag started. A slow, hand-speed drag always
started while the pointer was still over the field, so it never moved.

The Select tool now checks for a movable comment under the press first
(select_grabs, sharing fill_grabs' hit logic) and only lets the form
field take it when there is none. Away from comments, fields fill as
before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:40:09 -04:00
Shawn Skaggs 40a4b411ce
Fill & Sign: Add text keeps what was typed on click-away, and its box grows (#918)
Clicking elsewhere on the page with Add text still selected replaced the open
box before it saw its lost focus, so the typed text vanished without becoming
an annotation. The click now commits the open box before starting the next one.

The type box never widened because its egui Area caps the TextEdit at last
frame's size. Setting the area's minimum width lets the box grow (and shrink)
with its text. Width is now counted in characters, not bytes.

Regression tests: clicking_elsewhere_with_add_text_keeps_the_typed_text,
the_type_box_grows_as_text_is_typed (both fail before this change).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:39:55 -04:00
DWAA1660 bfbeee9fa9
Edit text: white or pale text is edited on a dark box instead of vanishing on white (#913) (#921)
The inline Edit text box was always filled white and drew the text in its
own colour, so white text from a coloured banner disappeared while editing.
The box is now white or a dark grey, whichever contrasts more with the text
(WCAG contrast), and the caret follows the box.

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:39:39 -04:00
Tevinhead 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 <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 17:38:34 -04:00
Joe Abou Serhal 892f0b78f0
M14: give Windows PDF files a dedicated document icon (#890)
* M14: give Windows PDF files a dedicated document icon

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

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

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

* Refresh catalog hashes after merging main

---------

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

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

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

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

* M4: keep the bookmarks panel note in English

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

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

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

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 17:17:13 -04:00
cyfung1031 910b70bb5f
M7: keep embedded CMap ranges compact instead of truncating expansion (#837)
* M7: keep embedded CMap ranges compact instead of truncating expansion

A ToUnicode CMap's sequential bfrange used to expand into one HashMap
entry per code, and the parser stopped at 2^20 expanded codes. A valid
CMap with more codes than that lost every mapping past the cap.

Each sequential range is now one record. Codes below 65 536 find their
covering record in a table, higher codes by binary search over a cover
index; the later declaration wins where ranges and explicit entries
overlap. Explicit entries keep their strings. The budget counts an
explicit entry as one and a range as two, since a range covers at least
two codes, so every CMap with at most 2^20 codes is kept whole. encode
finds the lowest code per character from the same records.

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

* ToUnicode: size the low-code tables to the codes a font covers

---------

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

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

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

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

* M14: offer a 50 % interface size

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 17:13:52 -04:00
snipeTR 9c2d9c02bf
fix(crypt): R2–R4 passwords with non-ASCII characters open (PDFDocEncoding) (#905)
Revisions 2–4 of the standard security handler take the password in
PDFDocEncoding (ISO 32000-2 §7.6.4.3.2, Algorithm 2). PdfCraft authenticates
an encrypted file twice, and neither did that:
- pdfcraft-crypt took Latin-1 and dropped every other character, so "ı"
  (0x9A) and "€" (0xA0) vanished and "ılık€" became "lk";
- the renderer (vendored hayro-syntax, "TODO: Convert to PDFDocEncoding")
  used the UTF-8 bytes, so any non-ASCII password failed, even "Çok güzel",
  which Latin-1 and PDFDocEncoding write alike;
- the engine handed the renderer an owner-recovered user password as Latin-1
  characters, which the renderer then read as UTF-8 again.
Files that qpdf or Acrobat protected with such passwords could not be
opened with the right password.

- pdfcraft-crypt encodes R2–R4 passwords in PDFDocEncoding and, for a
  password with characters PDFDocEncoding lacks (ğ, ş, İ), also tries its
  UTF-8 bytes, which qpdf writes for those. The PDFDocEncoding table moves
  here from pdfcraft-cos (which depends on this crate) and cos uses it.
- hayro-syntax (vendored patch 14) tries an R2–R4 password as given, then
  in PDFDocEncoding.
- The engine passes a recovered user password to the renderer as UTF-8 when
  its bytes are UTF-8 and as PDFDocEncoding otherwise, so the renderer gets
  exactly those bytes back.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 17:10:39 -04:00
snipeTR 9035ae3c82
fix(create): read UTF-16, byte-order-marked and Windows code page text files (#903)
Text files were decoded with String::from_utf8_lossy wherever they are
read: Create PDF and Combine from a .txt file, the automation `create`
tool's text `path`, and Search & Redact's word list file. So
- a UTF-16 file (Windows Notepad's "Unicode") became NULs and replacement
  characters ("??G?r?????e?" for "Grüße"),
- a UTF-8 byte order mark became a stray character at the start, and
- a Windows-1252 or 1254 file lost every accented letter ("caf�", "K��").
For a word list that means the words were never found and stayed
unredacted.

pdfcraft_create::decode_text picks UTF-8 or UTF-16 (LE/BE) by the byte
order mark and drops it, keeps valid UTF-8, and otherwise decodes the
Windows code page: 1254 when the bytes 1254 uses for Ğ İ Ş ğ ı ş appear
without á í ó ú (which Turkish never writes but Icelandic and Faroese,
the only Western languages using those bytes, always do), else 1252.
encoding_rs is already in the build through lopdf.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 17:07:47 -04:00
snipeTR 9b3b98d2e2
fix(redact): the Email pattern finds internationalized addresses (#901)
The Email pattern of Search & Redact accepted only ASCII letters and
digits, so addresses such as "ayşe.yılmaz@örnek.com.tr", "müller@bücher.de"
or "иван@пример.рф" (RFC 6531 local parts, IDN domains) were not found
and stayed visible after a redaction by pattern.

Letters and digits of any script written with spaces now count in the
local part, the domain and the top-level domain. Scripts written without
spaces (Chinese, Japanese, Korean, Thai, …) still don't, so text that
runs straight into an address, as in "メールはtaro@example.jpまで", is not
swallowed into it. A domain still needs a dot and an alphabetic top-level
domain of two or more letters.
2026-10-10 17:04:41 -04:00
snipeTR d1d003c318
fix(redact): Turkish, German, French and Italian month names in the Dates pattern (#900)
The Dates pattern of Search & Redact knew only English month names, so
"10 Ekim 2026", "3 Mayıs 2025" or "5. Oktober 2026" stayed visible after a
redaction by pattern. It also lowercased with str::to_lowercase, under
which "MAYIS" never equals "mayıs" and "EKİM" grows a combining dot.

Month names are now listed for English, Turkish, German, French and
Italian, with English and Turkish abbreviations ("Şub.", "Ağu"), and are
compared one character at a time with I, i, İ and ı folded to i, so any
case matches. A day may end in a full stop before the month name
("5. Oktober 2026"). Words that only begin like a month ("Martı",
"Ekimde", "Oktoberfest") and month names without a day and year still
don't match.
2026-10-10 17:04:27 -04:00
Joel Tony f45fd7507e
Issue #870: keys leave the document under a dialog alone (#898)
* Issue #870: keys leave the document under a dialog alone

With Preferences (or any other dialog) open, the registered shortcuts still
ran on the document underneath it: ⌘W / Ctrl+W closed the file, ⌘Z undid an
edit, ⌘F opened Find. In Organize Pages the grid's own keys did too, so Delete
or Backspace deleted the selected pages behind the dialog, and the grid took
the Escape meant to close it.

Every modal now owns the keyboard, as "Save changes?" already did:
PdfCraftApp::modal_open covers the dialog, the save, password and link
prompts and the update check. Shortcuts stop there, and the organize grid's
keys and autoscroll wait until the page is unobstructed.

* Contributors: credit jay-tau by their GitHub profile name
2026-10-10 17:04:14 -04:00
80aa08 603cefe213
M14: Add Polish interface language (#782)
* M14: Add Polish interface language

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

* Polish catalog: keyboard key names

* Refresh catalog hashes after merging main

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #801.

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

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

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

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

ocr_recognize_files_refuses_paths_outside_the_root checks this without the
models.

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

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

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

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

Fixes #814.

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

Fixes #804.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

(cherry picked from commit eaf3180bd8ebb353b3d8c5ca7b9ef7548ed55c37)

---------

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

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

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

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

---------

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

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

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

* Format

---------

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

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

* M13: preserve desktop Action Wizard batch results

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

* ROADMAP: date the desktop action outputs entry today

---------

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

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

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

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

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

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

Fixes #816.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Without `?host=parent` nothing changes.


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

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

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

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

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

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

(cherry picked from commit c7db1e80a374e80a11504efefbaac680dfa5e872)

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #645

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #801.

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

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

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

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

ocr_recognize_files_refuses_paths_outside_the_root checks this without the
models.

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

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

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

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

Fixes #814.

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

Fixes #804.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

(cherry picked from commit eaf3180bd8ebb353b3d8c5ca7b9ef7548ed55c37)

---------

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

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

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

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

---------

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

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

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

* Format

---------

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

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

* M13: preserve desktop Action Wizard batch results

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

* ROADMAP: date the desktop action outputs entry today

---------

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

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

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

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

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

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

Fixes #816.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Without `?host=parent` nothing changes.


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

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

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

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

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

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

(cherry picked from commit c7db1e80a374e80a11504efefbaac680dfa5e872)

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #645

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #801.

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

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

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

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

ocr_recognize_files_refuses_paths_outside_the_root checks this without the
models.

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

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

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

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

Fixes #814.

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

Fixes #804.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

(cherry picked from commit eaf3180bd8ebb353b3d8c5ca7b9ef7548ed55c37)

---------

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

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

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

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

---------

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

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

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

* Format

---------

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

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

* M13: preserve desktop Action Wizard batch results

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

* ROADMAP: date the desktop action outputs entry today

---------

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

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

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

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

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

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

Fixes #816.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Without `?host=parent` nothing changes.


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

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

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

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

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

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

(cherry picked from commit c7db1e80a374e80a11504efefbaac680dfa5e872)

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #645

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

pdfcraft-annot gains `set_fill` (writes or removes /IC and redraws the
appearance; lines, ink and locked comments refuse it) and reports
`fillable`/`fill` in the comment properties. The engine adds
Edit::FillAnnotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019XAJjko29kzKj7ULXoXSnN

* M5: update ATTRIBUTION.toml hashes for the edited i18n catalogs (#686)

The fill-colour strings changed 13 translation catalogs, so
xtask's the_real_manifest_passes failed on ubuntu and windows CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:37:04 -04:00
DWAA1660 3906dcdff5
Exported images keep their transparency, JPEG cut-outs included (#645) (#763)
* M10: exported images keep their transparency, JPEG cut-outs included (#645)

Export all images and Save image as wrote a JPEG with a soft mask as the
bare JPEG and dropped soft masks of another size, JPEG-compressed soft
masks, stencil /Mask images and colour-key /Mask arrays, so cut-out
images came out on a solid white or black background.

- An image with transparency (/SMask, stencil /Mask, colour-key /Mask) is
  written as a PNG with it as alpha, JPEG images included (decoded with
  the image crate's JPEG decoder, already in the tree). JPEGs without
  transparency are still written byte for byte.
- Soft and stencil masks of another size are scaled to the image's size;
  /Decode inversion is honoured and /Matte pre-blending is undone.
- Image sizes are checked (positive, at most 100 Mpx) before use, so a
  negative /Width no longer overflows in Save image as.
- Regression tests: JPEG + soft mask, scaled soft mask, stencil mask,
  colour key, matte, and damaged masks/sizes (all fail or panic on main).

Fixes #645

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: use as_chunks for colour-key pairs (clippy 1.99 chunks_exact_to_as_chunks) (#645)

Rust 1.99's clippy denies chunks_exact with a constant size, which failed
the Clippy step on all CI targets. as_chunks::<2> also drops the indexing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:31:41 -04:00
Silver 8309c84a59
M12: XFA rows in areas get their instances, and areas stay out of data paths (#853)
A Designer form whose table (a top-to-bottom subform inside an area of a
positioned page) asks for several rows with `<occur min="10"/>` was laid
out with one row, and a FormCalc total looping over the rows stayed
empty.

- layout: a repeating subform inside a top-to-bottom container in
  positioned content is drawn and measured once per instance (occur and
  data), with its own SOM index, as flowed content already was. One
  `instances` helper now serves flowing, table rows and this case.
- data: areas are named in SOM expressions but are not data scopes.
  `data_path(tpl, som)` leaves them out (unknown segments are kept), and
  layout, the script tree, reading and writing the datasets and the
  engine's value and row operations use it. Before, values Adobe's
  viewers saved were not read, what was filled here went under a new
  element named after the area (which Adobe's viewers would most likely
  not read), and scripts counted the rows there: one, so a loop over
  the table's rows failed on the missing ones.
- write_datasets_with / read_values_with take the parsed template when
  the caller has it (the engine's cached one); the old functions parse
  the document's.
- Tests: layout regression, data_path rules, and an engine round trip
  shaped like such a form (rows, Adobe-written values, a FormCalc total
  over every row, the data written back, reopening).

Signed-off-by: Hadi Chokr <hadichokr@icloud.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:28:29 -04:00
Epsilon-LLC 6116b0034c
M9: name the Windows store certificates that can't sign (#179) (#761)
A store certificate PdfCraft can't use (no private key, a P-521 /
Brainpool / Ed25519 key, a key CNG can't open) was dropped from the
Sign dialog silently, so a user whose CA-issued identity shows in
Acrobat but not here had nothing to go on. `windows::list` now returns
those under `unusable` with the subject, fingerprint and a reason that
names the Windows error code; the Sign dialog shows them greyed and
unselectable with the reason, and `sign_windows_ids` returns them.
A key that needs a prompt to open (NTE_SILENT_CONTEXT, NTE_UI_REQUIRED
while listing) is listed and prompts at signing instead of being
skipped. Certificates without a private key stay out of the dialog but
are reported by the tool.

Tests: the explained-error table, the store listing's invariants, a
key-less certificate in the ignored store test, the automation output,
and a kittest dialog test for a greyed entry. Verified by hand that a
.p12 imported into the CNG and legacy Microsoft providers lists, and
that a P-521 certificate is explained.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:25:24 -04:00
taylorandrew07-lab 4dafbfc325
M11: Reduce file size and Optimize PDF drop images nothing draws, such as a deleted image (#894)
Deleting an image with Edit a PDF removes its Do operation, but the image stays in the
page's /Resources /XObject, so every full rewrite (Reduce file size, Optimize PDF)
carried its data along: a page left blank still weighed the whole picture.

Clean Up now drops image and form XObjects from a page's own resources when their name
is written in no stream the document still uses. Shared and inherited resources are left
alone, and if any stream can't be read the way a viewer would read it, nothing is removed:
viewers look names up through parent resources, so only a name written nowhere is safe to
drop. doc_optimize reports unused_xobjects.

Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:21:07 -04:00
taylorandrew07-lab 4fd2c2b3a3
M11: Reduce file size shrinks office scans, and never saves a bigger copy (refs #490) (#849)
Office scanners write the default decode array (/Decode [0 1 0 1 0 1]) on their page
images. Reduce skipped every image with a decode array, so a scan stayed the same
size. The default array is now treated as absent; any other array still skips the
image.

Reduce also saved its copy even when it wasn't smaller, reported as "-N% smaller".
It now saves nothing and says the file is already as small as it can be made;
doc_reduce returns "written": false. Optimize PDF still saves (what it discards
may matter more than the size) but says "not smaller".

Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:20:50 -04:00
Ikki Nishihara 74be522c18
hayro-syntax: cap the nesting of direct arrays and dictionaries (#772)
Skipping a direct array or dictionary, and the lenient repair of a
dictionary that has an object where a key belongs, recursed once per
level with no bound, so deep nesting in a trailer entry or a content
stream overflowed the stack. Count the depth on the Reader and fail
beyond 128 levels like any other malformed object.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 15:54:26 -04:00
Ikki Nishihara 559859ca61
lopdf: bound object lookups through compressed containers and object-stream members (#771)
A compressed object's container was resolved with a fresh cycle set and
without checking that it is an ordinary object, and indirect /Length
chains had no depth limit, so self-containing or chained lookups
overflowed the stack. Pass the caller's set on, require an in-file
container and cap the chain at 16 lookups. Object-stream members were
each parsed to the end of the decoded stream, so repeated or nested
offsets kept many copies of one large object: parse each member within
its own extent and skip repeated offsets.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 15:51:25 -04:00
cyfung1031 c8c77efb14
M4: make page-label generation lazy across large documents (#845)
* M4: make page-label generation lazy across large documents

Page labels were expanded into one String per page, and the whole sequence was refused once the labels passed 4 MiB in total. A valid document with long labels could therefore neither be read nor edited. Add a PageLabels view over the label ranges: label(page) builds one label, and iter() builds the labels one at a time, so no string is kept per page. Edits validate each range from its largest number (Roman numerals by their thousands) instead of building every label, so the check no longer depends on the page count.

The per-label length limit, the number-tree work and depth limits and the overflow checks are unchanged. page_labels() keeps its signature and returns the same labels as before for ordinary documents.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* M4: drop the aggregate page-label cap from document inspection

Document inspection refused every custom label once their total passed 4 MiB, so a valid document with long labels showed physical page numbers and a warning. Labels are now applied to every page. The per-label length limit, the number-tree work, depth and cycle limits and the prefix bounds are unchanged, and labels are still applied only after every range is accepted.

MAX_LABEL_TOTAL_BYTES is removed. The inspector keeps one label per page (PageInfo.label), so its label memory grows with the page count times the label length. The page count is bounded by the objects in the file, and each label by 1,024 bytes.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* M4: keep the inspector's 4 MiB page-label cap until its labels are lazy

Commit a096ece removed the aggregate page-label cap from document inspection. Inspector::fill keeps one label String per page on open, and inspection does not read labels from the lazy PageLabels view yet, so a hostile file with a million labelled pages would allocate on the order of a gigabyte when it is opened (measured here at about 1.8 KiB per labelled page). This reverts that commit. The cap stays until inspection reads labels lazily, as a separate change. The organizer change in 5253124 does not depend on it.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-10 15:51:10 -04:00
Tristan von Kries 8c182087df
Issue #390: build the DMG with hdiutil create so the app passes codesign --strict (#823)
* Issue #390: build the DMG with hdiutil create so the app passes codesign --strict

makehybrid -hfs wrote a com.apple.FinderInfo (Finder location -1,-1) on every
file in the image, and codesign --verify --deep --strict rejects that as
"Finder information, or similar detritus", on the app in the DMG and on a copy
installed from it. hdiutil create -srcfolder writes the files as they are; it
attaches a device while copying, so it gets up to five tries. A gate mounts
the finished image read-only and verifies the app inside with --strict.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

* Issue #390: retry hdiutil attach and detach in the DMG gate too

As suggested in review: the gate mounts the signed image under set -e, so a
"Resource busy" there would stop a release after the DMG was built. The same
five tries with growing pauses now cover create, attach and detach.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

---------

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 15:50:55 -04:00
Aviral Bansal b21045de6e
Single instance: enable tab handoff in Windows portable mode (#889) (#891)
* Single instance: enable tab handoff in Windows portable mode (#889)

* Single instance: a portable copy's socket stays in %LOCALAPPDATA%, under its own name

(cherry picked from commit c7db1e80a374e80a11504efefbaac680dfa5e872)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 15:50:42 -04:00
MarekSpodBiedry 2a909f361c
Print: a scaled annotation appearance no longer prints larger than its rectangle (#802) (#861) 2026-10-10 15:50:20 -04:00
CyberFox-81 b34b3263d6
Web: opt-in embedding bridge for host pages (?host=parent) (#842)
A page that frames PdfCraft on the same origin can now pass it PDFs and
receive saves through postMessage, instead of Save becoming a browser
download. Built for a Nextcloud integration, but nothing in it is
host-specific.

- ui-egui: HostSaveFn/HostDirtyFn hooks (`host_save`, `host_dirty`),
  None by default. On wasm, Save hands the bytes to `host_save` when
  set; `host_dirty` hears each frame whether any tab has unsaved work.
- pdfcraft-web: `host::Bridge`, enabled only by `?host=parent` inside a
  frame. Messages: pdfcraft:ready / open / save / dirty / failed; only
  the parent window on our own origin is heard or answered.
  `?author=` names comment authors unless the user chose a name.
- Test: host_dirty_hears_when_unsaved_work_appears_and_goes.

Without `?host=parent` nothing changes.


Claude-Session: https://claude.ai/code/session_01G6GtUCaPiCMEQCN4QcdQnp

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 15:50:06 -04:00
LloydNicholson 5ad2ecfef9
M5.7: Fill & Sign tools pick up placed signatures to move and resize them (#822)
A signature placed with Fill & Sign could only be moved with the Select
tool. While a Fill & Sign tool was active (Sign, Initials, Add text,
Check, Date...), hovering the signature showed the placement crosshair,
a drag did nothing and a click stamped another mark on top of it.

Now, as in Acrobat, a Fill & Sign tool picks up the Fill & Sign marks
already on the page: hovering one shows the move cursor, a click selects
it, a drag moves it and the selected mark's handles resize it. Away from
them the tool places as before, including on other comments. Image
signatures keep their live move/resize preview.

Comments now carry a fill_sign flag (pdfcraft_annot::is_fill_sign for
the engine's list, the same rules in the viewer's inspector), so this
also works on reopened files.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:47:58 -04:00
MarekSpodBiedry c49148a290
Extract/Combine: a page repeated in one selection gets its own annotations (#818) (#852) 2026-10-10 15:47:45 -04:00
gyurix c933782039
Linux: "Use system setting" asks the desktop, not winit (#881)
winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is
`None` there on every desktop — so `ThemePreference::System` never saw the
desktop's choice and silently fell back to whatever was on screen: PdfCraft
came up light on a dark desktop, and picking "Use system setting" after Dark
kept it dark. The default setting did not follow the system.

`pdfcraft-platform` now reads the XDG Desktop Portal's Settings interface off
the UI thread and the app follows the answer, including a change made while it
runs. The colour scheme is asked for under its GNOME schema name first, which
GNOME's and XFCE's portals both answer, then under the older freedesktop one,
which KDE answers. A desktop that answers neither leaves the user's own choice
alone, exactly as before.

Co-authored-by: GyuriX <gyurix@users.noreply.github.com>
2026-10-10 15:47:33 -04:00
Ikki Nishihara 1b6e144c57
JavaScript: limit util.printf widths and output, and array arguments (#770)
util.printf accumulated a conversion's width and precision with unchecked
arithmetic and no maximum, and its output had no bound; the array
arguments of resetForm and the field value/colour setters were read for
as many items as the array's length says, which a sparse array makes
free. Saturate the numbers and refuse widths and precisions above 4096,
output above 1 MiB and arrays longer than 2^20 with a script error,
before anything is allocated from them.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 15:47:05 -04:00
MarekSpodBiedry 265ddca58c
organize: drop named destinations and their links when a page is deleted (#817) (#851) 2026-10-10 15:46:52 -04:00
mlkonrad 1845d9260d
Issue #878: Combine stores an image once when its colour space is an indirect name (#883)
ImageMagick writes an image's colour space as its own object (`/ColorSpace 10 0 R`,
`10 0 obj /DeviceRGB`). Each source's copy of that object was never merged, since it
isn't a resource, so identical images compared unequal and every copy was kept:
8 copies of an 80 MB ImageMagick PDF combined into 648 MB instead of 81 MB.

Dedupe now compares a reference to a plain value (name, number, boolean or string) as
that value. The value objects themselves are still never merged. Combine, Insert Pages
and Reduce File Size share this code.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:46:38 -04:00
才倾梦华V 7c386fa4ac
M10.2: scan JPEG metadata beyond frame headers (#764)
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 15:46:22 -04:00
DWAA1660 cac2e8bf41
Notice bar: the message wraps beside its buttons and lines up with them (#785) (#832)
The notice bar above the pages (security, forms, XFA, repaired file,
warnings, signatures) laid its message out before its buttons. In a
narrow document area (a side panel open, a longer translation) the
message ran under "Security settings" and on into the side panel, and it
wasn't centred on the buttons.

A shared notice_bar helper now lays the buttons out first, right to
left, centred on a 28 px row, and gives the message the width that's
left: it wraps there (cut short with the full text on hover when less
than 120 px is left) and its first line is centred on the buttons' row.

Regression test: editing::the_security_notice_wraps_beside_its_buttons_and_lines_up_with_them
(one line, wrapped and cut-short widths; fails on main).

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:44:29 -04:00
cyfung1031 ef63ba3680
Windows: add reproducible security diagnostics for storytold/pdfcraft#747 (#769)
Add packaging/windows/security-audit.ps1, a read-only maintainer helper that records a
release file's SHA-256, Authenticode status and, with an external YARA binary and rule,
every matched string identifier and offset. Document the procedure in docs/releasing.md
("Scanner detections").

No application, dependency, installer or network behaviour changes: the diff touches only
the helper and the release documentation.

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-10 15:39:44 -04:00
MarekSpodBiedry d258764abc
Keep every part when bookmark titles repeat in doc_split (#800) (#863) 2026-10-10 15:39:29 -04:00
Justin John 3cd619e937
Viewer: touchpad scrolling tracks the finger 1:1 (#759) (#868)
A precision touchpad sends pixel-precise `MouseWheel` events. On Windows
they arrive with no `TouchPhase::Start`, so egui never takes its
no-smoothing "in touch" path and eases any delta of 8 px or more over
several frames, leaving the page a frame behind the finger.

The page view now applies precise (Point-unit) wheel input itself, 1:1,
through the same un-eased scroll path as keyboard and middle-button
auto-scroll, and suppresses egui's eased copy for a short window so its
internal easing tail neither doubles nor trails the motion. A coarse
mouse wheel (Line/Page units) keeps egui's smoothing, which is pleasant
for chunky notches, and single-page paging still owns the vertical axis.

Regression test: one 200 px precise swipe scrolls the full 200 px at
once (an eased path would ramp there over ~0.1 s) and leaves no drift.

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-10 15:39:15 -04:00
Tristan von Kries 7a1e0bce86
Issue #813: a cancelled recovery password no longer attaches to the next unlocked document (#858)
Recovering an encrypted snapshot parked its metadata in `pending_recovered`
until the password prompt was answered. Cancel left it there, and the next
encrypted document unlocked in the foreground took the snapshot's original
path and recovery key: Save would overwrite the other file and delete its
snapshot.

The association now travels with the prompt (`PasswordPrompt::recovered`).
Cancel puts the entry back on offer and leaves the store alone; a wrong
password keeps it on the re-raised prompt; the correct one finishes the
recovery of the tab that was just opened. `pending_recovered` is gone.

Test: crash with an encrypted snapshot of A, Recover, Cancel, open encrypted
B with its password. B keeps its own path and is clean, A's snapshot stays
in the store, also after closing B. Fails on main at "B keeps its own path".

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 15:38:59 -04:00
MarekSpodBiedry f620750934
Web: warn before reloading a document with unsaved changes (#812) (#856) 2026-10-10 15:38:42 -04:00
MarekSpodBiedry f6caba43b2
XFDF keeps a callout's leader, text-box inset, intent and arrowhead (#819) (#854)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 15:38:27 -04:00
DWAA1660 4b74f655de
XFDF keeps polygon and polyline vertices (#809) (#848)
Export wrote polygon and polyline comments without their points and import
had nowhere to read them from, so an XFDF round trip left the comments with
no /Vertices and no appearance. Export now writes the points as XFDF
<vertices>x,y;x,y;…</vertices>, and import reads them back (whole x,y pairs
only) before the appearance is drawn.

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 15:35:25 -04:00
snipeTR 259002da97
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.
2026-10-10 15:32:07 -04:00
MarekSpodBiedry 6fd053b918
organize: deleting the opening page drops the catalog OpenAction (#821) (#855) 2026-10-10 15:31:51 -04:00
muraa-p eb75e5a9d9
fix(links): find URLs when lowercasing changes the text length (#816) (#830)
find_urls case-folded a copy of the page text and then sliced the original
characters with offsets taken from that copy. Case folding can change a
string's length — U+0130 (İ) lowercases to "i" plus a combining dot — so the
offsets drifted past the end of the source and `chars[i..j]` panicked. The CLI
exited 101 before it could create the link; MCP reported an internal tool error
but kept serving.

Match the ASCII prefixes case-insensitively against the original characters
instead, so every offset stays in the source's own index space, and use get()
so no lookup can index past the end.

A new test reproduces the reported panic on the old code (range start index 41
out of range for slice of length 39) and passes with the fix. pdfcraft-annot's
tests, clippy and rustfmt are green.

Fixes #816.

Co-authored-by: Big Pickle <noreply@opencode.ai>
2026-10-10 15:31:35 -04:00
MarekSpodBiedry c7b9db8faa
Fit to width fits the shown page in single-page view (#808) (#859) 2026-10-10 15:31:20 -04:00
Tevinhead 40a57f0d19
M13: preserve desktop Action Wizard batch results (#886)
* M13: preserve desktop Action Wizard batch results

Co-Authored-By: Codex <noreply@openai.com>

* ROADMAP: date the desktop action outputs entry today

---------

Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 15:31:06 -04:00
Nigel Ihza Firdausy fc08d56e97
fix(automation): retain batch results with identical filenames (#877)
* fix(automation): retain batch results with identical filenames

* Format

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 15:29:47 -04:00
Krzysztof Cieślik f54aa1db1e
fix(editing): recognize paragraph drafts as unsaved work (#811) (#843)
* view: check Shift-Command-G before Command-G for find navigation (#810)

* fix: recognize paragraph drafts as unsaved work (Issue #811)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 15:11:19 -04:00
Krzysztof Cieślik 724f13cced
fix(view): check Shift-Command-G before Command-G for find navigation (#810) (#835)
* view: check Shift-Command-G before Command-G for find navigation (#810)

* Tests: the find shortcut test waits for find by a deadline

(cherry picked from commit eaf3180bd8ebb353b3d8c5ca7b9ef7548ed55c37)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 15:08:55 -04:00
MarekSpodBiedry 7608632db1
fix(xfdf): drop the replaced note's popup when re-importing a comment (#807) (#860) 2026-10-10 15:08:32 -04:00
muraa-p 5ab928d529
fix(comments): include replies to replies in the comment summary (#820) (#840)
The comment summary gathered a comment's direct replies only, matching /IRT against the comment itself. A reply to a reply points at the direct reply, so its text never reached the generated summary even though the stored discussion kept it.

Replies are now walked to any depth, with a bound, and indented one level per reply. The existing output for direct replies is unchanged.

The new test builds a rectangle, a reply and a reply to that reply, then checks the summary carries both texts with the nested one indented under its parent. It omitted the nested reply before the fix.

Co-authored-by: Big Pickle <noreply@opencode.ai>
2026-10-10 15:08:18 -04:00
mlkonrad c7fcee4ed9
OCR: a scanned euro sign reads as €, not E (#796)
The ocrs crate's default alphabet has an E where its recognition model was
trained with € (ocrs-models DEFAULT_ALPHABET), so a scanned "€250" was
recognised as "E250". pdfcraft-ocr now gives ocrs the alphabet the model was
trained with.

the_alphabet_is_the_trained_one checks the alphabet without the models;
recognize_text_reads_the_euro_sign reads a scan end to end when they are
installed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:08:04 -04:00
muraa-p f8334d0cd3
fix(edit): refuse a header start number that would wrap the next page (#804) (#834)
Page numbering adds the start number to the position of the page in the run. At the top of the u32 range that addition wrapped, so a release build saved zero for the second page and still reported success, and a debug build hit a caught overflow error instead.

Check the whole run before drawing anything. When the last page number would pass the u32 limit the operation returns an actionable error and the document keeps no header, rather than a half applied run.

Fixes #804.

Co-authored-by: Big Pickle <noreply@opencode.ai>
2026-10-10 15:07:51 -04:00
Tristan von Kries 010b677829
Issue #774: keep the catalog out of object streams in encrypted files (Acrobat) (#836)
Acrobat reports an encrypted PDF as "damaged and could not be repaired", before
asking for the password, when the document catalog sits in an object stream.
Every other reader tried (qpdf, pypdf, poppler, PDFKit, PdfCraft) accepts such
files, and qpdf keeps the catalog standalone too. Full saves of encrypted
documents now write the catalog as its own object; unencrypted output is
unchanged. Verified with Acrobat DC on macOS: the same file fails with the
catalog packed and opens with it standalone, in AES-256 and RC4-128.


Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 15:07:38 -04:00
muraa-p 9c4414c50e
fix(comments): keep a reply to a reply in the comment listing (#806) (#839)
comment_list collected only the replies whose /IRT pointed at the root comment, so a reply to a reply was listed nowhere at all. Roots and the root count were already right; the nested reply was simply dropped from its thread.

Follow the /IRT chain to any depth, with a bound, so every reply under a comment is listed in document order. A reply to a reply now stays discoverable through the tool that promises every comment and its replies.

The new test builds a root note, a direct reply and a reply to that reply, then checks both texts are listed and the two notes stay roots. It listed only the direct reply before the fix.

Co-authored-by: Big Pickle <noreply@opencode.ai>
2026-10-10 15:07:10 -04:00
muraa-p 4f7aa22214
fix(edit): keep a comment whose appearance is missing when flattening (#805) (#838)
Viewers draw notes, FreeText, ink and stamps from their dictionary when the file stores no /AP /N. flatten only drew annotations that already had an appearance and then removed the rest, so a supported note disappeared and the saved page came back blank.

Build the appearance a viewer would show for every selected, visible, non-widget annotation that has none, then draw it as usual. A subtype this build cannot render still fails to build and is handled exactly as before.

The new synthetic test draws the note icon into the page and asserts it is baked in; without the fix flatten reports 0 drawn and the icon is lost.

Co-authored-by: Big Pickle <noreply@opencode.ai>
2026-10-10 15:06:56 -04:00
muraa-p e0625cd5cf
fix(text): match phrases across repeated whitespace (#814) (#831)
The phrase search collapsed runs of whitespace in the query but not in the page text it was matching against, so a document drawn with two space glyphs in a row like the issue example never matched the phrase. Single words still matched, which is why it looked like only some files were affected.

Collapse whitespace while the text is flattened, the same way the query is normalised. The regression test fails on the old code because the phrase find returns an empty range, and it also covers tabs and single word queries.

Fixes #814.

Co-authored-by: Big Pickle <noreply@opencode.ai>
2026-10-10 15:06:43 -04:00
mlkonrad f9ed8b264a
OCR: Recognize text in multiple files refuses paths outside the root (#795)
ocr_recognize_files turned a path outside the automation root into a per-file
error and returned success, so no_tool_escapes_the_root reported 9 leaks once
the OCR models were installed. Without them, loading the models failed first
and hid it, and CI never installs them. Every path is now checked before any
work starts, and one outside the root refuses the whole call, like the other
tools. A missing file inside the root stays a per-file error. Nothing was ever
read from outside the root.

ocr_recognize_files_refuses_paths_outside_the_root checks this without the
models.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:06:29 -04:00
MarekSpodBiedry f3f4019fad
Autosave: snapshot a new unsaved document before its first edit (#815) (#850) 2026-10-10 15:06:15 -04:00
muraa-p 527128d007
fix(organize): reject rotation angles that are not multiples of 90 (#801) (#833)
Page rotation only turns in quarter steps, but the degree value was read with an integer division by 90 that threw the remainder away. Asking for 45 degrees saved an unrotated page, 91 and 135 saved a 90 degree turn, and the command still reported success.

Reject the value before touching the document instead, so every caller gets an actionable error and the pages are left alone. The automation tool already rejected it; the single edit command did not.

Fixes #801.

Co-authored-by: Big Pickle <noreply@opencode.ai>
2026-10-10 15:06:02 -04:00
MarekSpodBiedry 8ae9f6be8b
Measure: snap to the implicit closing edge of filled paths (#803) (#862) 2026-10-10 15:05:48 -04:00
Brandon Thomas 798650e8a0
Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#873)
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, PDF-spec, hardware, UI and localization parity docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:23:28 -04:00
cyfung1031 5d59060486
M3: make render watchdog regressions deterministic (#699)
* M3: gate watchdog tests without timed sleeps

* test(render): replace watchdog timing delays with gates

* Render tests: the panic test doesn't wait on the gate's finished barrier

Only an abandoned render reaches it, so waiting there hung under load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:33:40 -04:00
Márk Bartalos 9cd07e4793
M8: redact lists of words or phrases (protect.redact-word-lists) (#757)
* M8: redact lists of words or phrases

Find text and redact gains "Multiple words or phrases": a list with one word
or phrase per line, typed or imported from a .txt file (Import list…), whose
every match is marked in one undo step. Lists are trimmed, deduplicated and
bounded (1,000 entries of 256 characters; files up to 1 MB are read).
redact_mark takes words and reports matched_words, the marks each entry made.

protect.redact-word-lists is shipped; whole-word and case-sensitive matching
and exporting the list are still missing.

* M8: translate word-list labels into Arabic

The Arabic catalog arrived on main after this branch, so the new Find
text and redact strings were still English and failed
arabic_covers_ui_literals.

* Tests: drop sharp grid renders when the thumbnail will cover the cell

Zooming the organize grid back out left the sharp page raster in place
until a thumbnail texture existed, so the zoom test failed when that
texture had not arrived yet.

* M8: Czech rows for the word-list redaction labels

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:32:09 -04:00
Márk Bartalos aa6242d0c6
M4: bookmarks from tagged headings (organize.bookmarks-from-structure) (#754)
* Parity: merge the duplicate form.field-list evidence and notes

#649 and #650 each added `evidence` and `notes` to the same entry, so the
TOML has duplicate keys and `cargo xtask parity` fails on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: bookmarks from tagged headings (organize.bookmarks-from-structure)

Bookmark options > New bookmarks from structure (command
bookmark.from_structure, tool bookmark_from_structure) turns a tagged
document's headings into bookmarks, as Acrobat's New Bookmarks From
Structure does for headings: H1-H6 through the role map, a plain H by its
Sect/Part/Art nesting, nested by level under a new first "Untitled"
bookmark, each linked to its structure element (/SE, ISO 32000-2
§12.3.3). Titles come from /ActualText, the marked text drawn on the page
(decoded through the font's ToUnicode or encoding), /Alt or /T.

The structure walk is bounded (depth, visits, heading and title caps,
seen-set) and survives self-referencing elements; an untagged document
gets an actionable error; the edit is one undo step under the assemble
permission. Not yet: choosing other element types, and destinations at the
heading's position rather than its page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: size the shared raster allowance from thumbnail demand

The canvas test planted a 132×171 thumbnail whose tag did not match the
request finish_render_frame computes. On FreeBSD that real render finished
in the same frame, so the retained raster exceeded 132×171×4. Plant a
thumbnail that already satisfies demand and compare against that size.

* M4: a heading's text stops gathering once it is far past the title length

A heading whose marked-content list repeats one mark up to the visit cap could otherwise collect
about a gigabyte before the title is cut to 256 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 08:25:10 -04:00
DWAA1660 0bc619cd02
Extract pages: a File name field names the extracted files (#737) (#751)
* Extract pages: a File name field names the extracted files (#737)

The Extract pages dialog had no way to name the result: separate files
were always "<document> (page N).pdf" in the chosen folder, and a new
tab was "<document> (extract).pdf".

The dialog now has a File name field, filled in with the document's
name when it opens. Separate files are "<name> (page N).pdf"; a single
renamed page is "<name>.pdf"; extracting into a new tab names the tab
(and so the Save As suggestion) "<name>.pdf". The name is made safe for
every platform (separators, reserved and control characters, trailing
dots and spaces, any-case ".pdf"), and falls back to the document's
name when nothing usable is left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Extract pages: Windows device names get a prefix, and the name is capped at 200 bytes

"NUL.pdf" writes nowhere on Windows, and 200 CJK characters overflow the 255-byte name limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 08:20:08 -04:00
Silver 49a8d04f24
M9: sign and timestamp encrypted documents (#753)
* M9: sign and timestamp encrypted documents

Signing a password-protected PDF failed with "not supported: signing
encrypted documents". The writer encrypted every string, including the
signature's /Contents placeholder, which the signer patches in place
after hashing.

- cos: a signature dictionary's /Contents is never encrypted or
  decrypted (ISO 32000-2 §7.6.2), identified by /Type /Sig|/DocTimeStamp
  or by /Contents + /ByteRange. Existing signatures in encrypted files
  now read their real CMS and survive rewrites of their dictionary.
- sign: the blanket refusal is replaced by the document's permissions
  (Table 22) when opened with the user password: existing fields and
  document timestamps need fill-forms, new fields need annotate +
  modify, certifying needs modify. Refusals name the permissions
  password.
- Tests for all four algorithms, counter-signing, tampering,
  permissions, document timestamps and the sign_document tool; parity
  entry sign.encrypted-documents; ROADMAP log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hadi Chokr <hadichokr@icloud.com>

* M9: signing an encrypted document waits for a pending security change to be saved

Otherwise the incremental save becomes a full rewrite that applies or removes protection while
signing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: Hadi Chokr <hadichokr@icloud.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 08:19:45 -04:00
Márk Bartalos 7a33d4bf25
M9: the signer's certificate must allow document signing (key usage, EKU, critical extensions) (#749)
* Parity: merge the duplicate form.field-list evidence and notes

#649 and #650 each added `evidence` and `notes` to the same entry, so the
TOML has duplicate keys and `cargo xtask parity` fails on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M9: the signer's certificate must allow document signing (key usage, EKU, critical extensions)

A trusted signer whose certificate may not sign documents was reported
Valid. Validation now checks the signer certificate (RFC 5280 §4.2.1.3,
§4.2.1.12, §4.2): a keyUsage needs digitalSignature or nonRepudiation, an
extended key usage needs a document-signing purpose (any, emailProtection,
codeSigning, RFC 9336 documentSigning, Adobe Authentic Documents, Microsoft
document signing), and no critical extension may be one PdfCraft doesn't
process. Otherwise the verdict is Unknown and the details say why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M9: a critical qcStatements extension doesn't make a qualified certificate unusable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 08:14:55 -04:00
cyfung1031 e4e39f4bef
M4: keep the render pool across metadata edits (#750)
* M4: keep the render pool across metadata edits

Metadata edits (SetInfo) no longer rebuild the render pool or the display
bytes. The engine refreshes the document-information fields, the file size,
the signatures (when present) and the working bytes, and keeps the renderer.
Undo and redo of metadata edits reuse the same pool.

Comment and form edits still rebuild the renderer and their view data.

Tests:
- metadata_edits_and_history_reuse_the_render_pool
- field_and_comment_edits_still_rebuild_the_render_pool
- metadata_edit_keeps_the_renderer_without_changing_page_content (the
  metadata invalidation audit; its assertions now expect the kept renderer)

Supersedes #711 and #712.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* M4: a metadata edit reads the other Info entries as the full inspection does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 08:10:32 -04:00
Abdelhaq El Amraoui 1c33e6f851
M14: Arabic interface language (#692)
* M14: Arabic interface language

A 2,136-entry Arabic catalog (six CLDR plural forms, Arabic month and
weekday names), selected by `ar` and every `ar-*` locale. egui joins
Arabic letters but places words left to right, so the catalog is put
into display order when it loads (bidi::display_rtl); placeholders,
keyboard shortcuts and caption colons stay in place. The command
search matches a multi-word Arabic query word by word.

The craft-fonts pin moves to the revision with Noto Sans Arabic.
crates/fonts/build.rs now refuses to embed Adobe type designs, so the
Noto Sans CJK SC in that revision is never embedded (AGENTS.md §1.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M14: Arabic catalog follows the {key} shortcut labels and has Redaction code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:36:54 -04:00
Todd Pederson ebc5ccd4c0
M14: View menu, tooltips and Keyboard shortcuts show Ctrl, not ⌘, off macOS (#695)
View ▸ Zoom and Page navigation were hand-written with "⌘1", "⌘[" … while
the registered items below them were formatted by Shortcut::label, so
Linux showed both ⌘1 and Ctrl+K in one menu. Ten tooltips and the view-key
rows of Help ▸ Keyboard shortcuts hard-coded ⌘ the same way.

- The keys the document view handles itself are Shortcut constants in
  ui-egui commands.rs, written with the same Shortcut::label.
- Labels follow egui's detected OS (ctx.os()) instead of cfg!, so the web
  build in a Linux or Windows browser shows Ctrl too, and tests can check
  both platforms.
- Translated tooltips take a {key} placeholder (13 catalogs; only the
  shortcut token changed, hashes updated in ATTRIBUTION.toml).
- Fit visible is listed once in View (in Zoom), not also in the generated
  list; Ctrl+3 and the palette still run it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:32:06 -04:00
Márk Bartalos bfa65a9bfc
M8: redaction code sets (U.S. FOIA, Privacy Act) as overlay text (#741)
* M8: redaction code sets (U.S. FOIA, Privacy Act) as overlay text

Redaction properties offers "Custom text" or "Redaction code" under "Use overlay
text". Picking the U.S. FOIA (5 U.S.C. 552(b)) or U.S. Privacy Act (552a) set and
ticking codes makes new marks carry them as overlay text, in the set's order.
redact_mark takes code_set and codes and rejects unknown sets or codes with the
allowed list.

Also merges a duplicated evidence/notes pair in form.field-list that made
parity/acrobat-features.toml fail to parse on main.

* M8: say why the default redaction code set can't fail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: pin the clock in form_bulk (saves a second apart differed in /ModDate on Windows CI)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:27:26 -04:00
Leo e742e29771
Windows: single instance, launches started together share one window (#282, #317) (#715)
* Windows: open files in the running PdfCraft instead of a new window each time (#282, #317)

Outlook attachments, Explorer double-clicks and Open With start pdfcraft.exe with the file,
so every file got its own process and window. Now the first PdfCraft listens on a Unix domain
socket in %LOCALAPPDATA%\PdfCraft (or a portable copy's data folder), one per Windows session.
A later launch that only names files sends their absolute paths, waits for "ok" and exits; the
running app opens them as tabs through the existing OsEvent::Open path (the one macOS Apple
events use) and comes to the front.

Anything else keeps today's behaviour: --new-window, any other option, a launch without files,
or any failure to hand over within 5 seconds opens a window of its own. Other platforms are
unchanged.

The socket lives in the user's profile, so other accounts can't create, replace or reach it.
uds_windows (already in the lockfile through zbus) provides it: std has no Unix sockets on
Windows and the workspace forbids unsafe. A socket left by a crash is replaced on the next start.

* Windows: launches started together share one window (#282, #317)

Several files selected in Explorer and opened at once start one process
per file at the same moment. A launch could then find the socket bound
by another launch that was not listening yet: connecting failed, clearing
the socket failed with "Access is denied", and the launch opened a window
of its own.

Launches now take turns through a lock on a file beside the socket, held
only while a launch finds out whether it is the first. A socket that
cannot be cleared because another launch holds it is tried again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016es5tszobbb1yZ7JeozJXL

* Windows: a portable copy doesn't hand files to another instance

Its data folder may be writable by other accounts, so a socket there could leak or inject file
paths. Builds on art804's single-instance work in #602.

Co-authored-by: art804 <7828385+art804@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: art804 <7828385+art804@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:23:11 -04:00
DWAA1660 e4cd9e2557
PDF/A: no sRGB output intent for CMYK documents, and pdfa_verify checks the intent's colour space (#667) (#732)
* PDF/A: no sRGB output intent for CMYK documents, and verify checks the intent's colour space (#667)

pdfa_convert always added an sRGB output intent when there was none,
even for documents whose only content is DeviceCMYK, and pdfa_verify
then called them compliant: it only checked that some PDF/A output
intent existed. ISO 19005-2 6.2.4.3 allows DeviceCMYK only with a CMYK
output intent, and DeviceRGB only with an RGB one.

- verify reads the PDF/A output intent's destination profile (/N, else
  the ICC header's data colour space) and reports DeviceCMYK under a
  non-CMYK intent and DeviceRGB under a non-RGB one (6.2.4.3, not
  fixable).
- convert adds the generated sRGB intent only when the document has no
  DeviceCMYK; a CMYK document keeps the existing "needs a CMYK output
  intent (not added automatically)" issue instead of a wrong intent.
- Device colour detection tells gray, RGB and CMYK apart (gray works
  under any intent).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PDF/A: DefaultRGB/DefaultCMYK resources satisfy the output-intent colour check

ISO 19005-2 6.2.4.3 accepts device colour drawn through a default colour space under any output
intent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:22:47 -04:00
cyfung1031 742c42ab63
M2: cancel obsolete in-flight requests (#702)
* render: cancel obsolete in-flight requests

* #623: recheck cancellation after busy publication

* #623: record rendering session in roadmap

* M2: a request cancelled during the parse doesn't leave its worker unable to render

The abort was cached as the worker's parse result, so every later request on that worker failed as
unparsable. Also drops ROADMAP edits that described other PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:22:24 -04:00
cyfung1031 7fafadc3a2
M2: text turned at any angle reads as lines along its own baseline (#735)
* M2: text turned at any angle reads as lines along its own baseline

The text layer sorted every glyph into the nearest of four quarter turns
and laid it out by its axis-aligned box. Text at 30-60 degrees lost its
word gaps, split into one line per word in reverse order, or interleaved
two neighbouring lines letter by letter; text set along a curve came out
in fragments.

Each glyph now keeps its drawn em box as a quad and its writing
direction. Glyphs within the layout's word-gap tolerance (sin 0.15) of a
quarter turn take the existing path unchanged. Other glyphs are laid out
in a frame along their own baseline, one flow per direction; runs that
continue each other while turning (stamps, seals) are unrolled into one
line. Turned text keeps the place in the reading order that its first
glyph had before.

Horizontal, quarter-turn, skewed and near-axis text is bit-identical
(text, rect, line_of, space_before) over pdf_engine_test.pdf, the
crates' test PDFs and 300 synthetic pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M2: look up a turned glyph's direction among directions only, not every curved run

Curved runs can number about half the glyphs, which made grouping quadratic on hostile pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:19:54 -04:00
cyfung1031 d94da2038a
M2: pages with /UserUnit are shown at their size in points, as in Acrobat (#722)
* M2: pages with /UserUnit are shown at their size in points, as in Acrobat

/UserUnit gives the size of a page's user-space unit in points; PdfCraft
ignored it everywhere except the measuring tools, so a /UserUnit 2 page
was shown, extracted, printed and edited at half its size, and the
measuring tools disagreed with the rest of the app.

The value is now read as Acrobat reads it (the page's own entry, a number
from 1 up, at most 75,000; anything else counts as 1) in two places:
- vendored hayro-syntax Page (patch 13): render_dimensions and
  initial_transform give the page in points, so rendering, tiles,
  PageInfo and the text layer follow; a page without it keeps exactly
  the transform it had.
- pdfcraft-model Page: user_unit(), and display_size/view_matrix in
  points, so print, header/footer, watermark, background, added content
  and measuring agree with the screen. The measure crate drops its own
  arithmetic. Added items record the /UserUnit they were written for;
  items without the record (written before) read back converted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M2: an added item's recorded /UserUnit is clamped like the page's

An infinite or huge recorded /UserUnit made the scale factor 0 and collapsed the item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:19:31 -04:00
mmmaarian03-crypto 99c7dd9311
Sign: smart cards and tokens in the Windows store ask for their PIN (#694) (#742)
* Sign: decide Windows store keys in code every platform builds

#646 fixed the Windows build after #386 with `_` arms in the two
matches in windows.rs. That module is cfg(windows), so Linux and macOS
CI never compile it, and the next PublicKey variant will either break
the Windows build again or, now, fall into `_` without anyone choosing.

The store's two decisions move into keys.rs, which every platform
builds, with every variant named and no `_` arm:
- PublicKey::store_signing_key: RSA, or ECDSA with 256/384 bits, or
  None for the kinds PdfCraft only verifies with (P-521, brainpool,
  Ed25519, Unsupported).
- PublicKey::store_signature_der: CNG's raw r||s as DER (RSA as is);
  the verify-only kinds return SignError::Unsupported, as since #646.

windows.rs calls both, with no `_` arms left. Behaviour is unchanged.

tests/store_keys.rs runs everywhere: every variant's answer (with an
exhaustive match, so a new variant must be added to it), the P-256 and
P-384 conversion round-tripped through PublicKey::verify (code that
used to compile only on Windows), and malformed CNG signatures erroring
rather than panicking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sign: smart cards and tokens in the Windows store ask for their PIN (#694)

Signing with a Windows store identity whose key lives on a smart card or
USB token failed with NTE_SILENT_CONTEXT (0x80090022) and no PIN dialog.

`identities()` acquires each key with CRYPT_ACQUIRE_SILENT_FLAG so that
listing never prompts, and signing reused that handle after
`set_silent(false)`. That only drops NCRYPT_SILENT_FLAG from
NCryptSignHash: a provider handle that was opened silently stays silent,
so a card that needs its PIN refuses instead of asking.

`WindowsKey` now keeps the certificate context and acquires a fresh,
non-silent key handle when it signs, so the provider can show its own
dialog. Listing is unchanged and still silent.

Reproduced and checked by hand with a qualified certificate on a SafeNet
token (SafeNet Smart Card Key Storage Provider, RSA-2048): 0.5.0 fails
with 80090022, this build shows the SafeNet PIN dialog and signs. The
software-key round trip (`signing_with_windows_store_identities`,
--ignored) still passes on Windows 11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sign: a Windows store identity signs with its issuers

A digital ID file brings its certificate chain and the signature embeds
it; a Windows store identity signed with its own certificate only. A
certificate issued by an intermediate CA (the usual case on a token)
then validates only where the reader already has that CA.

`windows::find`, which fetches the identity to sign with, now fills
`chain` with the certificates Windows chains it to, without the root.
`identities()` still builds no chains, so listing stays fast and local.
A chain that can't be built leaves `chain` empty, as before.

Test `a_windows_store_identity_signs_with_its_issuers` (--ignored, like
the other store test): a temporary root, issuer and signer; the
signature validates against the root alone. The cleanup guard now also
removes the public copy New-SelfSignedCertificate leaves in Intermediate
Certification Authorities, which the existing test left behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: James Locke <336218001+jcl-bit@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: mmmaarian03-crypto <253881360+mmmaarian03-crypto@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:19:07 -04:00
Todd Pederson c5d886488c
M14: side panels keep their width after a resize (#704)
A resizable egui panel stores the width its content used at the end of
every frame and starts the next frame there, so a row wider than the panel
widens it on every repaint (any pointer move) until it reaches 520 px.

- Bookmarks: the search row reserved 32 px for an 8 px gap and a 26 px
  clear button, overflowing by 2 px. The field now reserves the button's
  size plus the theme's item spacing.
- Comments: the reply row reserved 56 px for the gap and "Post", which only
  fits English. The field now fills its line and Post sits right-aligned
  under it, keeping the field first in Tab order.
- Tests read egui's PanelState and hold each panel's width across 30
  repaints (Bookmarks with a bookmark; Comments with a reply open in
  Russian with Post hovered). On main they grow 358 → 418 and to 520 px.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:15:06 -04:00
LloydNicholson 06a7b9a7ca
Issue #666: images made into a PDF keep their ICC profile (#729)
Create from images (doc_create, and image_xobject for stamps, signatures and
inserted images) dropped a JPEG's or PNG's embedded ICC profile, so a CMYK
JPEG became untagged /DeviceCMYK.

- JPEG: the APP2 ICC_PROFILE chunks are joined by sequence number; a profile
  with a missing or repeated chunk is ignored.
- PNG: the iCCP profile is read.
- A profile whose header is sound (acsp) and whose colour space matches the
  image (GRAY/RGB/CMYK for 1/3/4 components) becomes [/ICCBased <stream>]
  with /N and the device space as /Alternate. Anything else keeps the device
  space, as before. JPEG data and an Adobe CMYK /Decode are unchanged.
- Images in one document that carry the same profile share one profile
  object.

TIFF profiles are not read yet.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:14:51 -04:00
jcl-bit d5b5687f79
Sign: decide Windows store keys in code every platform builds (#690)
#646 fixed the Windows build after #386 with `_` arms in the two
matches in windows.rs. That module is cfg(windows), so Linux and macOS
CI never compile it, and the next PublicKey variant will either break
the Windows build again or, now, fall into `_` without anyone choosing.

The store's two decisions move into keys.rs, which every platform
builds, with every variant named and no `_` arm:
- PublicKey::store_signing_key: RSA, or ECDSA with 256/384 bits, or
  None for the kinds PdfCraft only verifies with (P-521, brainpool,
  Ed25519, Unsupported).
- PublicKey::store_signature_der: CNG's raw r||s as DER (RSA as is);
  the verify-only kinds return SignError::Unsupported, as since #646.

windows.rs calls both, with no `_` arms left. Behaviour is unchanged.

tests/store_keys.rs runs everywhere: every variant's answer (with an
exhaustive match, so a new variant must be added to it), the P-256 and
P-384 conversion round-tripped through PublicKey::verify (code that
used to compile only on Windows), and malformed CNG signatures erroring
rather than panicking.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:14:36 -04:00
cyfung1031 fb548a7064
fix(annot): preserve strokes with malformed interior colors (#708) 2026-10-10 06:14:21 -04:00
cyfung1031 997b82d49c
test(render): cover pattern recursion cycles (#710) 2026-10-10 06:14:05 -04:00
cyfung1031 7a8445376e
M3: compare stitched render partitions with whole page (#697) 2026-10-10 06:13:50 -04:00
cyfung1031 f109676f77
test(render): cover form recursion cycles (#709) 2026-10-10 06:13:12 -04:00
Oussama Dalhi f52f93bcd2
Close the command palette on outside click (#696) 2026-10-10 06:12:57 -04:00
cyfung1031 eca3a2f760
#570: require Windows resource compilation in CI (#724) 2026-10-10 06:12:41 -04:00
cyfung1031 4125e4138b
#570: declare PMv2 DPI awareness in manifest (#725) 2026-10-10 06:12:25 -04:00
Krzysztof Cieślik 627d822709
create: lift TIFF decoding buffer limit to allow print-sized images (#665) (#681)
* create: lift TIFF decoding buffer limit to allow print-sized images (#665)

By default, the tiff crate limits decoded buffer size to 256 MiB.
Set Decoder::with_limits(Limits::unlimited()) to match PNG decoding
behavior and prevent 'decoder limits exceeded' on large images.

* create: cap the TIFF decode buffer at 2 GiB instead of lifting it entirely (#665)

A few bytes claiming enormous dimensions must still be refused, not allocated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:12:10 -04:00
cyfung1031 a211e334a0
fix(render): key soft masks by inherited context (#716)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:11:45 -04:00
cyfung1031 72b3b6171a
hayro-syntax: decouple content tokenization from slices (#706) 2026-10-10 06:04:56 -04:00
Shaurya Swarup 198ef4dc92
Issue #727: tell the user when GitHub rate-limits update checks (#731)
Help ▸ Check for updates showed a bare "couldn't reach GitHub
(http status: 403)" when the anonymous hourly quota ran out. Read
error statuses in latest_release instead: a 403 now reports the
shared-quota limit with the retry time from X-RateLimit-Reset
("try again in about N minutes"), falling back to "try again later"
when the header is missing or spent. Other statuses keep the old
message. Adds rate_limit_messages_name_the_wait.

Co-authored-by: ShauryaSwarup <shaurya.swarup@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:04:44 -04:00
mmmaarian03-crypto f8c463416a
Sign: long digital ID names stay inside the picker (#743)
The rows of Sign with a Digital ID painted the name and the "Issued by"
line unclipped, so a long subject or issuer (a Windows store
certificate named after a GUID, for example) ran past the dialog's
right edge.

Both lines are now cut with "…" at the row's edge, and a cut row shows
its whole text on hover.

Test `long_digital_id_names_stay_inside_the_picker` fails before the
change.

Co-authored-by: mmmaarian03-crypto <253881360+mmmaarian03-crypto@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:04:32 -04:00
cyfung1031 de94e95994
#419: derive raster allowance test from demand (#723) 2026-10-10 06:04:17 -04:00
cyfung1031 e2b71e3e82
M5: unify annotation flag parsing (#698) 2026-10-10 06:04:03 -04:00
cyfung1031 0695fd6f16
fix(forms): reject out-of-domain field flags (#701) 2026-10-10 06:03:49 -04:00
cyfung1031 18e5db5e6d
test(render): cover inherited form resource contexts (#707) 2026-10-10 06:03:36 -04:00
cyfung1031 d84d6c859a
test(render): pin output across worker counts (#705) 2026-10-10 06:03:24 -04:00
cyfung1031 3d45b598ee
test(engine): audit edit history side effects (#713) 2026-10-10 06:02:55 -04:00
cyfung1031 916e0bc4d5
#419: cover suspended raster resource ownership (#733) 2026-10-10 06:02:41 -04:00
DWAA1660 c02fe8ca53
About dialog test: wait for each control to stop moving before clicking it (#671) (#703)
The About dialog is a modal centred on its own size. Switching to the
Contributors tab grows it, and for a few frames it recentres: here the
Table toggle sits at y=378, then 355, then 341. The test waited a fixed
four frames (#656) before clicking Table. Since the 0.5.0 contributors
refresh that is not always enough (deterministic on some Debian
machines, intermittent on the GitHub runners), so the click landed where
the button had been and the table never opened: "No nodes found matching
the query: By { label: \"PRs\" }" at links.rs:78.

Clicking after one frame reproduces exactly that failure. A `settled`
helper now runs frames until the target node exists and its rect is the
same on two frames in a row, so the test no longer depends on how many
frames the modal needs. The dialog itself is fine: a real click comes
long after the two or three frames the modal takes to settle.

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:02:28 -04:00
cyfung1031 e5e6ded35b
M5: constrain PDF font descriptor flags (#700) 2026-10-10 06:02:15 -04:00
cyfung1031 4339f5b9ed
#570: log desktop renderer details (#726) 2026-10-10 06:02:02 -04:00
cyfung1031 abe0366d26
M3: document why fixed-zoom sizes differ from Acrobat (no runtime change) (#736)
* Parity: document the Page Display resolution gap at fixed zoom

view.zoom-to-percent and view.zoom-actual-size are shipped with empty
notes. Record that PdfCraft's 100% is its fixed 96-dpi basis, that
Acrobat's fixed-percentage display also depends on its Page Display
Resolution preference, and that PdfCraft does not offer that preference
yet (tracked with misc.prefs-page-display). Status, commands, tools and
evidence are unchanged. No runtime behavior changed.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* Parity: state the Acrobat fixed-zoom formula in the zoom notes

The zoom percentage and Actual Size notes now give the measured rule
(logical px per PDF point = ppi / 72 × zoom), name the Custom
resolutions checked black-box, and say that Use System Setting is not
reproduced. Status, commands, tools and evidence are unchanged. No
runtime behavior changed.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-10 06:01:50 -04:00
cyfung1031 68e91d4815
macOS: the first Apple Korean key in a fresh PdfCraft composes (winit#4744) (#680)
* macOS: IME text committed after the marked text is cleared isn't lost (winit#4650)

winit 0.30.13 reports insertText as Ime::Commit only while marked text is
left, and after any commit it swallows the next key. Real paths where that
loses text:

- input methods that end a composition with unmarkText, then insertText
  (rust-windowing/winit#3925): the committed text was dropped;
- Apple Korean 2-Set, for the key typed after a syllable: on 한 then 5 it
  calls setMarkedText("한"), insertText("한"), setMarkedText(""),
  insertText("5"), so the 5 (or ;, or any key that ends the syllable) was
  dropped;
- a commit made outside a key press (a candidate clicked with the mouse,
  dictation): no key follows to reset the state, so the next key (for
  example Backspace) was swallowed.

vendor/winit now commits when a composition was started (pending_commit,
set in setMarkedText, cleared by the commit and when IME is disallowed);
other insertText calls still reach the app as KeyboardInput. A commit
outside keyDown clears the marked text and returns to the ground state.
validAttributesForMarkedText lists the underline and marked-clause
attributes, which some input methods need before they compose.

This is rust-windowing/winit#4650, the 0.30 backport of #4651 (merged on
winit master), plus the part of #4748 that fixes #4651's out-of-key-press
case (#4748's other parts are not included). One correction: upstream
spells the underline attribute "NSUnderlineStyle", but AppKit's
NSUnderlineStyleAttributeName is "NSUnderline" (printed on macOS 14), so
the AppKit value is used.

Checked with the real input methods on macOS 14.7.1 (a scratch winit app fed
key events through AppKit): with the patch 한+5 and 한+; commit the 5 and ;,
unmarkText-then-insertText commits, and Backspace after a commit outside a
key press is delivered; ABC typing, held keys, Japanese (日本, あ), Cangjie
(日) and Korean composition give the same events as before. A test fails if
a re-vendored winit loses the patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* macOS: the first Apple Korean key in a fresh PdfCraft composes (winit#4744)

On the first key after PdfCraft starts, Apple Korean 2-Set can answer
interpretKeyEvents with insertText and the raw compatibility jamo instead of
setMarkedText. winit sends that key to the app as an ordinary key, and the
next keys compose without it: g k s Space typed ㅎㅏㄴ instead of 한 (Search,
form fields, comments; rust-windowing/winit#3095).

vendor/winit now ports rust-windowing/winit#4744: keyDown interprets that
one key a second time, synchronously, before any KeyboardInput is sent, and
only when every condition holds: Apple Korean, IME allowed and not yet
composing, no pending commit or marked text, no Command/Control/Option, not
a repeat, the key window's first responder, the only callback is one
insertText of that same single jamo, and focus and input source unchanged
afterwards. The decision is ImeStartup (ime_startup.rs, copied with its
seven tests); no timer, no key queue, no text rewriting. It sits on top of
the previous macOS IME change (pending_commit, in_key_down); the retry runs
inside in_key_down. Command maps to ModifiersState::SUPER in winit 0.30
(META on winit master).

Checked with the real input method on macOS 14.7.1 (scratch winit app, fresh
process each time): without the patch g k s Space gave ㅎ as a key, then
ㅏ and ㄴ committed separately, 3 runs out of 3; with it, ㅎ → 하 → 한 → 한
committed, 3 out of 3. All other traces (Japanese, Cangjie, ABC, held
keys, later Korean keys) are unchanged. ImeStartup's tests pass with
rustc --test. A test fails if a re-vendored winit loses the patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:47:36 -04:00
cyfung1031 cf9f58fe87
macOS: Space in Apple Korean with no syllable in progress types one space (winit#4478) (#679)
* macOS: IME text committed after the marked text is cleared isn't lost (winit#4650)

winit 0.30.13 reports insertText as Ime::Commit only while marked text is
left, and after any commit it swallows the next key. Real paths where that
loses text:

- input methods that end a composition with unmarkText, then insertText
  (rust-windowing/winit#3925): the committed text was dropped;
- Apple Korean 2-Set, for the key typed after a syllable: on 한 then 5 it
  calls setMarkedText("한"), insertText("한"), setMarkedText(""),
  insertText("5"), so the 5 (or ;, or any key that ends the syllable) was
  dropped;
- a commit made outside a key press (a candidate clicked with the mouse,
  dictation): no key follows to reset the state, so the next key (for
  example Backspace) was swallowed.

vendor/winit now commits when a composition was started (pending_commit,
set in setMarkedText, cleared by the commit and when IME is disallowed);
other insertText calls still reach the app as KeyboardInput. A commit
outside keyDown clears the marked text and returns to the ground state.
validAttributesForMarkedText lists the underline and marked-clause
attributes, which some input methods need before they compose.

This is rust-windowing/winit#4650, the 0.30 backport of #4651 (merged on
winit master), plus the part of #4748 that fixes #4651's out-of-key-press
case (#4748's other parts are not included). One correction: upstream
spells the underline attribute "NSUnderlineStyle", but AppKit's
NSUnderlineStyleAttributeName is "NSUnderline" (printed on macOS 14), so
the AppKit value is used.

Checked with the real input methods on macOS 14.7.1 (a scratch winit app fed
key events through AppKit): with the patch 한+5 and 한+; commit the 5 and ;,
unmarkText-then-insertText commits, and Backspace after a commit outside a
key press is delivered; ABC typing, held keys, Japanese (日本, あ), Cangjie
(日) and Korean composition give the same events as before. A test fails if
a re-vendored winit loses the patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* macOS: Space in Apple Korean with no syllable in progress types one space (winit#4478)

With Apple Korean 2-Set on macOS 14, Space with no syllable in progress
(a second space, or a space after a digit or punctuation) makes AppKit
call setMarkedText(" "), insertText(" "), then setMarkedText(""). winit
reports the insertText as Ime::Commit(" "), but the trailing empty
setMarkedText returns its state to the ground state, so keyDown also sends
the key as KeyboardInput with text " ". egui inserts both, and PdfCraft's
text fields got two spaces (the double space of alacritty/alacritty#8079).

vendor/winit now records that the key being handled committed IME text and
then doesn't also send it as KeyboardInput, unless AppKit forwards it as a
command through doCommandBySelector (Enter after a syllable still commits
the syllable and sends Enter). keyDown consumes the record on every key,
so keys typed after IME is disallowed are unaffected.

rust-windowing/winit#4478 aims at the same double space, but its changes
(clearing the marked text on commit, forwarding a later insertText, and
dropping doCommandBySelector's guard) don't reach this call order, and the
key loss it also fixes is already fixed by the previous change
(pending_commit). This is the narrow form for the call order observed
here; it should go upstream with #4478.

Checked with the real input methods on macOS 14.7.1 (scratch winit app, on top
of the previous change): the duplicate KeyboardInput Space is gone and
nothing else changes (Korean syllables, 한+Enter, 한+5, Japanese, Cangjie,
ABC, held keys, keys after IME is disallowed). A test fails if a
re-vendored winit loses the patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep #678's emoji-picker branch: view.rs is main's plus this PR's Space fix (54b3d3c)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:46:49 -04:00
cyfung1031 3aa03d3c91
macOS: emoji from the emoji picker reach text fields (winit#4749) (#678)
* macOS: IME text committed after the marked text is cleared isn't lost (winit#4650)

winit 0.30.13 reports insertText as Ime::Commit only while marked text is
left, and after any commit it swallows the next key. Real paths where that
loses text:

- input methods that end a composition with unmarkText, then insertText
  (rust-windowing/winit#3925): the committed text was dropped;
- Apple Korean 2-Set, for the key typed after a syllable: on 한 then 5 it
  calls setMarkedText("한"), insertText("한"), setMarkedText(""),
  insertText("5"), so the 5 (or ;, or any key that ends the syllable) was
  dropped;
- a commit made outside a key press (a candidate clicked with the mouse,
  dictation): no key follows to reset the state, so the next key (for
  example Backspace) was swallowed.

vendor/winit now commits when a composition was started (pending_commit,
set in setMarkedText, cleared by the commit and when IME is disallowed);
other insertText calls still reach the app as KeyboardInput. A commit
outside keyDown clears the marked text and returns to the ground state.
validAttributesForMarkedText lists the underline and marked-clause
attributes, which some input methods need before they compose.

This is rust-windowing/winit#4650, the 0.30 backport of #4651 (merged on
winit master), plus the part of #4748 that fixes #4651's out-of-key-press
case (#4748's other parts are not included). One correction: upstream
spells the underline attribute "NSUnderlineStyle", but AppKit's
NSUnderlineStyleAttributeName is "NSUnderline" (printed on macOS 14), so
the AppKit value is used.

Checked with the real input methods on macOS 14.7.1 (a scratch winit app fed
key events through AppKit): with the patch 한+5 and 한+; commit the 5 and ;,
unmarkText-then-insertText commits, and Backspace after a commit outside a
key press is delivered; ABC typing, held keys, Japanese (日本, あ), Cangjie
(日) and Korean composition give the same events as before. A test fails if
a re-vendored winit loses the patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* macOS: emoji from the emoji picker reach text fields (winit#4749)

The macOS emoji picker (Fn/Globe-E, Control-Command-Space) inserts its text
with insertText:replacementRange: outside any key press and without a
composition. winit 0.30.13 reports insertText only as part of a key press or
a composition, so an emoji picked into Search, a form field or a comment
was dropped.

vendor/winit now reports text inserted outside a key press, with IME
allowed and no composition, as Ime::Commit, as rust-windowing/winit#4749
(the 0.30 backport of #4748) does. Builds on the pending_commit and
in_key_down state of the previous macOS IME change.

#4748's selectedRange change, which lets dictation start, is left out:
checked on macOS 14.7.1, a valid selection switches on press-and-hold for
every held key in a winit window, so holding e kept sending e key events
under an accent menu, and the accent picked with 1 arrived as a separate
commit after "eeee…1". Without it, held keys repeat as before.

Checked with a scratch winit app on macOS 14.7.1: an insertText outside a key
press now arrives as Ime::Commit; held keys, ABC, Japanese, Cangjie and
Korean typing give the same events as before. A test fails if a re-vendored
winit loses the patch or gains the selectedRange change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:45:51 -04:00
cyfung1031 3a86ce073d
macOS: an unfinished composition doesn't come back in the next text field (winit#4745) (#677)
egui disallows IME when a text field loses focus, for example when the user
clicks the page while composing. winit 0.30.13 then clears its own marked
text but never tells the input method, which keeps the composition and
continues it once IME is allowed again. Checked with the real input methods
on macOS 14.7.1: Korean 하, IME off and on, then r k gave 학, then 하 and 가
(하 committed a second time if the app already kept it); Japanese か, IME
off and on, then s a gave 傘.

vendor/winit now calls NSTextInputContext::discardMarkedText when IME is
disallowed with marked text present, before its existing reset, as
rust-windowing/winit#4745 does on winit master. With the patch the same
sequences give 가 and さ. AppKit may call unmarkText from
discardMarkedText while IME is still enabled, so its Preedit("") comes
before Disabled; the RefCell borrow in the check ends before the call.
Everything else in the scratch app's traces (ABC, Japanese, Cangjie,
Korean) is unchanged. A test fails if a re-vendored winit loses the patch.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:44:58 -04:00
cyfung1031 ec6c904cf3
macOS: IME text committed after the marked text is cleared isn't lost (winit#4650) (#676)
winit 0.30.13 reports insertText as Ime::Commit only while marked text is
left, and after any commit it swallows the next key. Real paths where that
loses text:

- input methods that end a composition with unmarkText, then insertText
  (rust-windowing/winit#3925): the committed text was dropped;
- Apple Korean 2-Set, for the key typed after a syllable: on 한 then 5 it
  calls setMarkedText("한"), insertText("한"), setMarkedText(""),
  insertText("5"), so the 5 (or ;, or any key that ends the syllable) was
  dropped;
- a commit made outside a key press (a candidate clicked with the mouse,
  dictation): no key follows to reset the state, so the next key (for
  example Backspace) was swallowed.

vendor/winit now commits when a composition was started (pending_commit,
set in setMarkedText, cleared by the commit and when IME is disallowed);
other insertText calls still reach the app as KeyboardInput. A commit
outside keyDown clears the marked text and returns to the ground state.
validAttributesForMarkedText lists the underline and marked-clause
attributes, which some input methods need before they compose.

This is rust-windowing/winit#4650, the 0.30 backport of #4651 (merged on
winit master), plus the part of #4748 that fixes #4651's out-of-key-press
case (#4748's other parts are not included). One correction: upstream
spells the underline attribute "NSUnderlineStyle", but AppKit's
NSUnderlineStyleAttributeName is "NSUnderline" (printed on macOS 14), so
the AppKit value is used.

Checked with the real input methods on macOS 14.7.1 (a scratch winit app fed
key events through AppKit): with the patch 한+5 and 한+; commit the 5 and ;,
unmarkText-then-insertText commits, and Backspace after a commit outside a
key press is delivered; ABC typing, held keys, Japanese (日本, あ), Cangjie
(日) and Korean composition give the same events as before. A test fails if
a re-vendored winit loses the patch.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:44:34 -04:00
cyfung1031 84b241831b
X11: the input method follows the text field that has focus (winit#4727) (#675)
egui calls Window::set_ime_allowed(true) when a text field gains focus and
set_ime_allowed(false) when it loses it. On X11, winit 0.30.13 handles each
change by destroying the window's X input context and creating a new one, and
leaves focusing the new context to the next FocusIn. PdfCraft's window keeps
the focus while the user moves between Search, form fields and the canvas, so
no FocusIn comes and the new context stays unfocused: the input method
(IBus, fcitx5) doesn't follow the field until the window loses and regains
focus.

vendor/winit's Ime::set_ime_allowed now returns create_context's result, and
the event processor, after draining the queued IME requests, focuses the last
context replaced for the active window, once, as
rust-windowing/winit#4727 does on winit master. No public API changes. A
test fails if a re-vendored winit loses the patch.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:42:29 -04:00
cyfung1031 9250e7542e
Windows: a window closed maximized reopens without a white flash (winit#4587) (#674)
eframe creates PdfCraft's window hidden, restores the saved state
(app.ron, persist_window) and shows the window after the first frame is
drawn. For a window closed maximized, winit 0.30.13 applies the maximized
state with ShowWindow(SW_MAXIMIZE), which shows a hidden window: PdfCraft
appeared unpainted (white), was hidden again, then shown with SW_SHOW and
SW_MAXIMIZE once more. rust-windowing/winit#4586 also shows the window's
focus state going wrong on this path.

vendor/winit now skips the maximize while the window is hidden and shows a
hidden window that is maximized with one SW_MAXIMIZE, as
rust-windowing/winit#4587 (approved upstream) does on winit master. That is
the later, focus-safe form of #4575; #4575's other change (re-sending
SW_MINIMIZE on every flag change while minimized) is not taken. Transitions
between two visible states make the same ShowWindow calls as before. A test
fails if a re-vendored winit loses the patch.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:42:06 -04:00
cyfung1031 5437390e7e
Windows: the Korean IME composition caret sits after the syllable (winit#4746) (#673)
When a composition has no target clause, winit 0.30.13 reads the cursor with
ImmGetCompositionStringW(GCS_CURSORPOS) without checking that the
WM_IME_COMPOSITION lParam has GCS_CURSORPOS set. The Microsoft Korean IME
never sets it (lParam 0x6018), so the read gives 0 and winit reports
Ime::Preedit("한", Some((0, 0))). egui 0.36.2 recognises that exact report on
Windows and drops the range, so PdfCraft draws no caret while Korean is being
composed.

vendor/winit now reads GCS_CURSORPOS only when the flag says the IME set it
and otherwise puts the cursor at the end of the composition, as
rust-windowing/winit#4746 does on winit master (Firefox and SDL do the same).
IMEs that report a cursor or a target clause (Japanese, Chinese) are
unchanged. A test fails if a re-vendored winit loses the patch.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:41:43 -04:00
cyfung1031 60a0518349
Wayland: IME keeps working after a seat loses a capability (winit#4747) (#672)
winit 0.30.13 destroys a seat's zwp_text_input_v3 in
SeatHandler::remove_capability, whichever capability went away (a touch
screen or tablet unplugged, a remote-desktop session ending), and only
new_capability recreates it. A seat that loses a capability without gaining
one leaves PdfCraft without a text input: IME (fcitx5, IBus) stops working
until PdfCraft is restarted.

The text input belongs to the seat, so vendor/winit now destroys it in
remove_seat only, as rust-windowing/winit#4747 does on winit master. A test
fails if a re-vendored winit loses the patch.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:39:50 -04:00
Eduardo Soares ade5dc4dcb
Print: list driverless CUPS destinations, not only permanent queues (#658)
* Print: list driverless CUPS destinations, not only permanent queues

The Print dialog's printer list came from `lpstat -p -d`, which lists only
permanent queues. A driverless IPP printer discovered on the network — the
standard setup on modern Linux desktops — has no permanent queue: only
`lpstat -e` (CUPS >= 1.7) names it, and CUPS builds a temporary queue when
`lp` sends it a job. Until now such printers were invisible and the dialog
showed only Save as PDF.

Enumerate `lpstat -e` alongside `lpstat -p -d` and merge the answers: queues
first, deduplicated, default still from `-d`; a spooler without `-e` keeps
today's behaviour and loses only the driverless names. The .deb/.rpm now
recommend cups-client so a machine without a print stack is not silently
printerless.

Fixes #652

Co-authored-by: ZCode (GLM-5.3) <noreply@z.ai>

* Print: fix the off-unix build and a driverless default's marker

`lpstat_e_command` is called only from `printers`' unix block, so off unix
it was dead code and CI's Windows clippy gate (`-D warnings`) failed to
build the crate. Gate it like its caller.

The default destination can itself be driverless — `lpoptions -d` names a
destination no queue exists for — and the merge appended it from
`lpstat -e` with `default: false`, so the dialog preselected the first
queue instead of the user's default. The `-d` default now marks the
appended entry too; the test covers it, and CRLF in `-e` output.

Co-authored-by: ZCode (GLM-5.3) <noreply@z.ai>

---------

Co-authored-by: ZCode (GLM-5.3) <noreply@z.ai>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:39:36 -04:00
cyfung1031 306bbcedf9
M3: harden render and CLI failure surfaces (#669)
* M3: harden render and CLI failure surfaces

Make content truncation observable, reject unknown CLI options, expose deterministic render work counters, and scope render failures to exact requests. Refuse silently reduced whole-page export rasters while preserving tiled rendering, and add heavy-vector and CLI regression coverage.

Co-authored-by: Codex <noreply@openai.com>

* Render: remember a failed render by page and kind again

A key with scale and tag made every zoom level and tile a new key: a
pathological page tied up a worker for the watchdog limit per request
(exhausting worker replacements) and was re-parsed each time. The
repeated-parsing check asks for a new generation and scale again.

* Export: pages too large at the chosen dpi export at the most the renderer allows, and say so

#669 made every Exporter refuse oversized whole-page renders, which
reached File > Export to image, doc_export_* and OCR: at 600 dpi any page
side over 13.65 in (Legal, A3, Tabloid) failed instead of exporting. The
exporter clamps again; Exporter::dpi_used reports the resolution a page
is drawn at, which the Export dialog's message and doc_export_png's
lower_dpi list mention. A source with reject_oversize still refuses
(CLI render keeps its strict config).

* doc_export_images: describe lower_dpi

* Render: ContentTruncated is reported once per page; the hayro-interpret patch is recorded

Nested forms, Type 3 glyphs and patterns are interpreted again and each
reported the exhausted budget; pdfcraft-render now keeps one. The warning
is part of hayro-interpret patch (18) (the page content budget): marked
in the code and described in vendor/README.md.

* CLI render: print render warnings (skipped content) to stderr

* CLI: the option check refuses unknown options only; commands report missing values themselves

text's own --page parsing (and its test on main) says what a page value
must be; the generic "needs a value" pre-empted it.

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:32:48 -04:00
cyfung1031 a5e378fd4b
M3: tiles skip paths that can't paint them (item 5 of #623) (#670)
A tile of a large page renders the whole page with an offset, and hayro
stroked and filled every path on it before vello discarded what fell
outside the tile. Vendored hayro patch (9), `may_paint_canvas`, skips a
fill or stroke whose box misses the canvas: the box of the path's points
and control points (a curve stays inside their hull), grown by the
stroke's reach (half the width times the larger of the miter limit and
sqrt 2), by 0.5 user units for vello's and kurbo's approximation
tolerances, and by 2 device pixels for antialiasing. Empty paths and
non-finite bounds or miter limits are always drawn.

Renders are byte-identical with and without skipping
(`skipping_paths_off_the_canvas_changes_no_pixel`; the hidden per-thread
`set_skip_offscreen_paths` exists for that test). On #623's sample at
scale 2, a 1024 px tile takes about 38% less CPU time; whole pages are
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:15:31 -04:00
cyfung1031 ef9b553f55
M2: kanji that Adobe-Japan1 maps to a variation sequence draw in non-embedded fonts (#662)
Adobe-Japan1-UCS2 maps the JIS X 0208 forms of the kanji that
JIS X 0213:2004 redrew to a variation sequence (CID 1247 is 噂 U+E0100).
hayro only looked up single characters in a substitute font, so a
non-embedded Japanese font drew 噂, 逢, 溢, 祇, 箸 and over 200 other
common kanji as unrelated glyphs. A character with a variation selector,
from Adobe-Japan1-UCS2 or a /ToUnicode, now draws the substitute's glyph
for that form when its cmap has one, else its glyph for the character.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:05:06 -04:00
cyfung1031 d63436ed10
M2: a character a substitute font lacks draws .notdef, not an unrelated glyph (#661)
A Type 0 font that isn't embedded is drawn with a substitute (a craft-fonts
Japanese face for Adobe-Japan1). When hayro couldn't find a CID's character
in the substitute by Unicode, it took the CID for a glyph index of the
substitute, which numbers its glyphs its own way: 𠮷 (CID 13706) drew as
"Q" in BIZ UDPGothic, ㈠ and the squared katakana words as unrelated
kanji. A CID of an Adobe character collection that the substitute can't
find now draws .notdef, unless the substitute is a CID-keyed CFF font,
which is looked up by CID as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:04:26 -04:00
cyfung1031 d67e79dd15
M2: a ligature stays one glyph in the text layer, in every writing direction (#660)
A glyph whose ToUnicode is several characters ("ffi", Arabic lam-alef, a
letter with its marks) was split into one glyph per character, with the
glyph box cut into equal parts along view x. Turned text and vertical
columns put the parts across the line, so "office" at 90° read
"o ce\nf\nf\ni"; narrow ligatures put two "f"s close enough to be dropped
as fake bold ("of ice"); right-to-left runs reversed the characters inside
the ligature ("سالم" for "سلام").

The glyph now keeps its text and its own box, as PDFium reports every
character of a ligature with the whole glyph box.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:04:11 -04:00
Tevinhead a99b39396f
M6: bulk form-field properties and selection (#95) (#650)
* M6: bulk-edit shared form field properties (#95)

Add opt-in shared properties, partial appearance patches and atomic CLI/MCP
batches. Complete current-page, rectangle and Fields-panel selection, with
20 synthetic regressions for preservation, rollback and dialog lifecycle.

Co-Authored-By: Codex <noreply@openai.com>

* Bulk field properties: translated messages, a catalog check, and in-place /DA patches

- The bulk dialog's flag label, errors and notices are in all 13 full catalogs; the missing-field error is a template (fmt + tl!) instead of format!. New test bulk_field_properties_labels_are_in_every_full_catalog.
- patch_da rewrites the last Tf and fill colour in place (appending only when there is none), so /DA doesn't grow per edit; sizes clamp to 300 like parse_da.
- A size- or font-only edit leaves widgets untouched; an indirect /MK or /BS is updated where it lives instead of inlined into each widget.
- Draft::new says why: a widgetless field gets its own message, not 'could not be found'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: pin the session clock in the bulk-fields harness (save_bytes stamps /ModDate)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:56:17 -04:00
Eduardo Soares a2f0fb7363
Forms: fields listed only as page widgets join the form (#649)
A PDF whose AcroForm /Fields array is empty or missing listed no
fields, so none were fillable even though every widget sat on a page
with its name and value: Acrobat and the browsers fill those. The page
widgets are now adopted as fields — after the tree-listed ones, through
each widget's topmost unlisted /Parent so a field split across several
widgets stays one — and the leniency is recorded, never silent.

- forms: fields() walks /Fields as before, then adopts the unlisted
  page widgets; adopted_page_fields() counts them for the note.
- render: the inspector's field list adopts them the same way, so the
  Fields panel, the field-count notice and menu gating follow.
- engine: opening such a file notes "the form's /Fields list omits N
  fields; they were read from the page annotations" in the repair log
  (Document Properties ▸ Advanced).

Co-authored-by: GLM-5.3 <noreply@z.ai>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 21:43:09 -04:00
cyfung1031 94752c09c4
macOS: add native document integration diagnostics (#653)
* macOS: add native document integration diagnostics

Add a read-only xtask command that reports bundle registration, nested code, signature, Quick Look, Spotlight, and bounded PDF metadata/text evidence without mutating system state.

* xtask macos-integration: decode UTF-16BE metadata without indexing (clippy as_chunks)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 21:35:51 -04:00
Brandon Thomas 29b0b0c572 Release: check the OCR models in the app tar.gz by name (the CLI tar.gz broke the glob)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:32:41 -04:00
Tanish Manku 0a8b230ca0
D1: pin and verify the pdf.js corpus (#339)
xtask corpus cloned mozilla/pdf.js at HEAD with no commit pin and no content check, and the corpus tests returned early when the directory was missing. So the corpus sweep measured a moving target, xtask/baselines/pdfjs.json was compared against whatever upstream happened to be that day, and a damaged or tampered checkout produced green results. AGENTS.md §2 asks for fixtures fetched pinned by commit and sha256-verified.

Verification is two layers. The commit fixes which tree we ask for; the manifest digest fixes what landed on disk: one '<sha256>  <path>' line per file, sorted by path, then one sha256 over that text. Git object ids are SHA-1, so the manifest is what makes this a sha256 check. The pin is 5d923c9 with 1455 files, which matches what the GitHub tree API reports for test/pdfs.

The checkout sets core.autocrlf=false and core.eol=lf. This repository has autocrlf=true globally, so without that git would rewrite line endings on checkout and the same commit would hash differently on Windows than on Linux; the pin has to mean the same thing everywhere.

A successful verification writes corpus/pdfjs/.pdfcraft-corpus.json. The corpus tests now skip only when nothing is fetched, and fail loudly when a checkout exists without that stamp. xtask check verifies before sweeping, so the baseline is never compared against an unknown tree.

Checked end to end: the unrecorded-pin guard refuses to fetch; --update-pin fetches and prints the constants; appending one byte to one of the 1455 files is detected and repaired by re-fetching; removing the stamp makes the corpus tests fail with a message saying what to run. Against the verified corpus the tests pass: 964 files ok and 7 password cases.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 21:26:54 -04:00
498 changed files with 115340 additions and 8046 deletions

76
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,76 @@
<!--
Thanks for contributing to PdfCraft.
Base this on `main` — it is the only integration branch. `main` moves fast (it took
396 commits in the three days to 2026-10-12), so a PR that sits for a day usually needs
a rebase. Rebasing the same day you are asked keeps the work from dying of conflicts.
Delete any section below that does not apply.
-->
## What this changes
<!-- What behaviour is different afterwards, and why it is worth doing. If it fixes an
issue, say `Fixes #123`. -->
## How you verified it
<!-- The commands you ran and what they said. Per-crate is fine and preferred:
`cargo test -p <crate>`. CI runs the full workspace on every PR, so you do not
need to locally. -->
- [ ] `cargo fmt --check`
- [ ] clippy with `-D warnings` on the crates I changed
- [ ] tests pass on the crates I changed
- [ ] `cargo xtask layers` and `cargo xtask assets` (if you touched crate boundaries or any asset)
**Tests.** New behaviour needs a test at the right scale — a property test, a unit test, a
crate integration test or an end-to-end one. For a bug fix, **please check that the test
fails before your fix** by reverting the fix and watching it go red. A test nobody has seen
fail is not evidence that it pins anything.
## Never-crash
Every PDF, script, MCP call, settings file and keystroke is untrusted input (`AGENTS.md` §4).
- [ ] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests, unless it is
provably infallible with a comment saying why
- [ ] No indexing or slicing with input-derived positions (`get`, and strings only at char
boundaries); checked or saturating arithmetic; allocations sized by input are capped;
recursion is bounded
- [ ] No `unsafe`
- [ ] Any crash this fixes has a small synthetic regression test
## Provenance
This is the part that cannot be undone after a merge, and the part a new contributor is
least likely to know about. Please read `AGENTS.md` §1 before adding any asset.
- [ ] No asset from an Adobe product, and no Adobe icon recreated, traced or imitated
- [ ] No font files committed here (they live in
[storytold/craft-fonts](https://github.com/storytold/craft-fonts))
- [ ] No GPL/AGPL code copied in — MuPDF, Ghostscript, Poppler, veraPDF and DSS may only be
run as external oracle processes
- [ ] Behaviour comes from the specification or black-box observation, **not** from reading
another implementation's source
- [ ] Every new asset has an `ATTRIBUTION.toml` entry and `cargo xtask assets` passes
## Security
- [ ] `--root` confinement still holds on every path, including ones reached by nested
tool dispatch
- [ ] MCP and the UI control channel stay opt-in, loopback-only and token-authenticated
- [ ] No new outbound network access (that decision is the owner's)
## Parity and reach
- [ ] `parity/acrobat-features.toml` updated in this PR, with `notes` saying what is *still*
missing — `shipped` means "exists and tested", not "as good as Acrobat"
- [ ] Reachable headlessly through an `automation` tool where that applies (`AGENTS.md` §3),
so agents can drive it and not just the UI
- [ ] Docs updated if this changes a documented behaviour (`docs/`, crate README)
## Anything a reviewer should look at first
<!-- The judgement call you are least sure about, a deliberate restraint you want pinned,
or a behaviour change existing users will notice. Say so here rather than leaving it
to be found. -->

View File

@ -20,6 +20,9 @@ jobs:
test:
name: test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_PROFILE_TEST_DEBUG: line-tables-only
strategy:
fail-fast: false
matrix:
@ -27,18 +30,24 @@ jobs:
# and release.yml builds macOS (a failing release is fixed and rebuilt).
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The optional craft-fonts build input (Japanese fonts), so the Japanese font tests run on
# Linux; Windows tests the build without it. See craftrules standards/fonts.md.
- name: Check out craft-fonts (Linux)
if: runner.os == 'Linux'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Configure craft-fonts (Linux)
if: runner.os == 'Linux'
run: |
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
- name: Install Linux system dependencies (egui/winit/rfd)
if: runner.os == 'Linux'
run: |
@ -47,11 +56,17 @@ jobs:
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
toolchain: stable
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- name: Require Windows executable resources
if: runner.os == 'Windows'
run: Add-Content -Path $env:GITHUB_ENV -Value 'PDFCRAFT_REQUIRE_WINRES=1'
shell: pwsh
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Format
if: runner.os == 'Linux'
@ -66,12 +81,10 @@ jobs:
- name: Test (with craft-fonts)
if: runner.os == 'Linux'
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: cargo test --workspace
- name: Layering
if: runner.os == 'Linux'
run: cargo xtask layers
- name: Assets (licences and attribution)
@ -86,11 +99,12 @@ jobs:
name: wasm32 check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
toolchain: stable
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: wasm32
- name: Check library crates for wasm32
@ -100,7 +114,7 @@ jobs:
name: dependency licences and advisories
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check
@ -110,9 +124,11 @@ jobs:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
toolchain: stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: fuzz
- name: Fetch the pdf.js corpus (seeds)
@ -121,7 +137,7 @@ jobs:
run: cargo xtask fuzz --time 900 --seed ${{ github.run_number }}
- name: Upload findings
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: fuzz-findings
path: fuzz-out/findings

View File

@ -23,30 +23,7 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
# The release packaging, end to end (a quicker release profile than the real release).
CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 packaging/freebsd/package.sh
tar -tzf dist/release/pdfcraft-*-freebsd-*.tar.gz | grep -E '/bin/pdfcraft$|/bin/pdfcraft-cli$|applications/|/share/pdfcraft/models/.+\.rten$'
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3

View File

@ -19,48 +19,5 @@ concurrency:
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/*.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon (#57).
if grep -oE '<Icon Id="[^"]*"' packaging/windows/pdfcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error file=packaging/windows/pdfcraft.wxs::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.pdfcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.pdfcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.pdfcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.pdfcraft.metainfo.xml"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.pdfcraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.pdfcraft" and m["command"] == "pdfcraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main

View File

@ -1,12 +1,3 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "PdfCraft v<version>". See docs/releasing.md.
#
# The jobs that sign (macOS, Windows) and the one that drafts the release run in the `release`
# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
# other jobs use no secrets and so no environment: a dispatch on another branch builds and checks
# them (a dry run), while the signing jobs are refused there and the draft-release job, which
# needs them, is skipped.
name: Release
on:
@ -20,408 +11,33 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
PDFCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### PdfCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
PDFCRAFT_VERSION: ${{ needs.version.outputs.version }}
PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately.
- name: Check out craft-fonts
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PDFCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM (#61): cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
PDFCRAFT_VERSION: ${{ needs.version.outputs.version }}
PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately.
- name: Check out craft-fonts
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$PDFCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build, sign, package
shell: pwsh
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
PDFCRAFT_VERSION: ${{ needs.version.outputs.version }}
PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
# The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately.
- name: Check out craft-fonts
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PDFCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz, CLI tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/pdfcraft$|metainfo|applications'
# The OCR models (#103), where the installed app looks: <bin>/../share/pdfcraft/models.
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/share/pdfcraft/models/.+\.rten$'
tar -tzf dist/release/*.tar.gz | grep -E '/share/pdfcraft/models/.+\.rten$'
ldd target/release/pdfcraft
# The CLI-only archive: the binary and its licences, and it runs.
cli="dist/release/pdfcraft-cli-$PDFCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
tar -tzf "$cli" | grep -E '/pdfcraft-cli$'
tar -tzf "$cli" | grep -E '/LICENSE-APACHE$'
mkdir -p "$RUNNER_TEMP/cli" && tar -xzf "$cli" -C "$RUNNER_TEMP/cli"
"$RUNNER_TEMP/cli/pdfcraft-cli-$PDFCRAFT_VERSION-linux-${{ matrix.arch }}/pdfcraft-cli" --version
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.pdfcraft.yml')); assert m['id'] == 'ai.storyteller.pdfcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
PDFCRAFT_VERSION: ${{ needs.version.outputs.version }}
PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/pdfcraft-$PDFCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD VM (see .github/workflows/freebsd.yml).
runs-on: ubuntu-latest
timeout-minutes: 180
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
PDFCRAFT_VERSION: ${{ needs.version.outputs.version }}
PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
VERSION_OVERRIDE: ${{ inputs.version }}
steps:
- uses: actions/checkout@v4
# The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately.
- name: Check out craft-fonts
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
envs: "PDFCRAFT_VERSION PDFCRAFT_BUILD_DATE VERSION_OVERRIDE"
# The same packages as the FreeBSD CI workflow, plus bash for the packaging script.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
if [ -n "$VERSION_OVERRIDE" ]; then cargo xtask version set "$PDFCRAFT_VERSION"; fi
packaging/freebsd/package.sh
# Only dist/ comes back out of the VM: copying the target dir back fails on large builds.
rm -rf target
- uses: actions/upload-artifact@v4
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
PDFCRAFT_VERSION: ${{ needs.version.outputs.version }}
PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
# The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately.
- name: Check out craft-fonts
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PDFCRAFT_VERSION"
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
name: Draft GitHub Release
# Needs the signing jobs, which only run on `release`: a branch dry run never drafts a release.
needs: [version, macos, windows, linux, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="PdfCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: pdfcraft
app-title: PdfCraft
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}

View File

@ -25,134 +25,10 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: pdfcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: pdfcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it, reinstall without the desktop shortcut
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
# A command-line override must not put files in a user profile while registering
# their handlers machine-wide (#305). This runs on a disposable CI runner.
$p = Start-Process msiexec.exe -WindowStyle Hidden -ArgumentList '/i', "`"$msi`"", '/qn', 'ALLUSERS=2', 'MSIINSTALLPERUSER=1', '/l*v', 'per-user.log' -Wait -PassThru
if ($p.ExitCode -ne 1603) { Get-Content per-user.log -Tail 60; throw "per-user install should fail with 1603, got $($p.ExitCode)" }
if (-not (Select-String -Path per-user.log -SimpleMatch 'PdfCraft must be installed for all users.')) {
Get-Content per-user.log -Tail 60; throw 'per-user install did not fail on the scope guard'
}
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$shortcuts = @(
(Join-Path ([Environment]::GetFolderPath('CommonPrograms')) 'PdfCraft.lnk'),
(Join-Path ([Environment]::GetFolderPath('CommonDesktopDirectory')) 'PdfCraft.lnk')
)
# Plain shortcuts to the program (#143), not advertised ones, so TargetPath is the exe.
$exePath = Join-Path $env:ProgramFiles 'PdfCraft\pdfcraft.exe'
$wsh = New-Object -ComObject WScript.Shell
foreach ($shortcut in $shortcuts) {
if (-not (Test-Path -LiteralPath $shortcut)) { throw "installer did not create $shortcut" }
$target = $wsh.CreateShortcut($shortcut).TargetPath
if ($target -ne $exePath) { throw "$shortcut points to '$target', not $exePath" }
Write-Output "ok $shortcut -> $target"
}
$dir = Join-Path $env:ProgramFiles 'PdfCraft'
foreach ($exe in 'pdfcraft.exe', 'pdfcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { Get-ChildItem -Recurse $env:ProgramFiles -Filter $exe | Select-Object -First 3; throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively: open, render and edit a small PDF through the engine.
$cli = Join-Path $dir 'pdfcraft-cli.exe'
& $cli tools | Out-Null
if ($LASTEXITCODE -ne 0) { throw "pdfcraft-cli tools exited $LASTEXITCODE" }
$body = 'BT /F1 24 Tf 20 150 Td (Hello ARM64) Tj ET'
$objs = @('<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>',
"<< /Length $($body.Length) >>`nstream`n$body`nendstream", '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>')
$sb = [System.Text.StringBuilder]::new("%PDF-1.7`n"); $offsets = @()
for ($i = 0; $i -lt $objs.Count; $i++) { $offsets += $sb.Length; [void]$sb.Append("$($i + 1) 0 obj`n$($objs[$i])`nendobj`n") }
$xref = $sb.Length
[void]$sb.Append("xref`n0 $($objs.Count + 1)`n0000000000 65535 f `n")
foreach ($o in $offsets) { [void]$sb.Append(('{0:D10} 00000 n `n' -f $o)) }
[void]$sb.Append("trailer`n<< /Size $($objs.Count + 1) /Root 1 0 R >>`nstartxref`n$xref`n%%EOF`n")
New-Item -ItemType Directory -Force smoke | Out-Null
[System.IO.File]::WriteAllText("$PWD\smoke\hello.pdf", $sb.ToString(), [System.Text.Encoding]::ASCII)
$steps = '[{"tool":"doc_open","args":{"path":"hello.pdf"}},{"tool":"text_paragraphs","args":{"doc":1,"page":1}},{"tool":"text_edit","args":{"doc":1,"page":1,"paragraph":1,"text":"Edited on ARM64"}},{"tool":"page_render","args":{"doc":1,"page":1,"dpi":72},"out":"page.png"},{"tool":"doc_save","args":{"doc":1,"path":"out.pdf"}}]'
[System.IO.File]::WriteAllText("$PWD\smoke\steps.json", $steps)
$out = & $cli run --script smoke\steps.json --root smoke 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "pdfcraft-cli run exited $LASTEXITCODE" }
if ($out -notmatch 'Hello ARM64') { Write-Output $out; throw 'text_paragraphs did not read the page' }
foreach ($f in 'smoke\page.png', 'smoke\out.pdf') { if (-not (Test-Path $f) -or (Get-Item $f).Length -eq 0) { Write-Output $out; throw "$f was not written" } }
Write-Output 'ok pdfcraft-cli: opened, read, edited, rendered and saved a PDF on ARM64'
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'pdfcraft.exe')) { throw 'uninstall left pdfcraft.exe behind' }
foreach ($shortcut in $shortcuts) {
if (Test-Path -LiteralPath $shortcut) { throw "uninstall left $shortcut behind" }
}
Write-Output 'ok uninstalled'
# Unattended installs can opt out of the desktop shortcut (#143); the Start Menu one stays.
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', 'INSTALLDESKTOPSHORTCUT=0', '/l*v', 'install-nodesktop.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install-nodesktop.log -Tail 60; throw "msiexec /i INSTALLDESKTOPSHORTCUT=0 exited $($p.ExitCode)" }
if (-not (Test-Path -LiteralPath $shortcuts[0])) { throw "INSTALLDESKTOPSHORTCUT=0 also skipped $($shortcuts[0])" }
if (Test-Path -LiteralPath $shortcuts[1]) { throw "INSTALLDESKTOPSHORTCUT=0 still created $($shortcuts[1])" }
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path -LiteralPath $shortcuts[0]) { throw "uninstall left $($shortcuts[0]) behind" }
Write-Output 'ok INSTALLDESKTOPSHORTCUT=0: Start Menu shortcut only'
test:
name: test (native ARM64)
runs-on: windows-11-arm
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: windows-arm64-test
- name: Test
run: cargo test --workspace --locked
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: pdfcraft
app-dir: PdfCraft

View File

@ -25,7 +25,7 @@ An **asset** is any non-code material that the project includes, ships, embeds,
- Screenshots of Adobe software exist only as private, local observation notes under `plan/acrobat/`, which is gitignored. They must **never** be committed, published, linked, shown in docs, used as a design source to trace, or placed anywhere else in the repository or its outputs.
- The only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed:
- The CMap resources (BSD-3-Clause), bundled inside the `hayro-cmap` dependency.
- The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces, and are embedded as code in hayro.
- The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces. They are embedded as code in hayro, and as our own generated table in `crates/fonts/src/std14_metrics.rs` (approved by the owner, 2026-10-08; that file holds the data alone, so the code reading it can change without re-attesting the table), which is what generated appearances measure with and what survives hayro being retired.
Adding anything to this list needs the user's explicit approval.

View File

@ -4,10 +4,11 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (259)
## In this repository (270)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
@ -17,11 +18,16 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@ -133,6 +139,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/messages-square.svg` | Lucide icon "messages-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/mic.svg` | Lucide icon "mic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minimize.svg` | Lucide icon "minimize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minimize-2.svg` | Lucide icon "minimize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minus.svg` | Lucide icon "minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/moon.svg` | Lucide icon "moon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/mouse-pointer-2.svg` | Lucide icon "mouse-pointer-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@ -196,7 +203,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/x.svg` | Lucide icon "x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/zoom-in.svg` | Lucide icon "zoom-in" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/zoom-out.svg` | Lucide icon "zoom-out" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths |
| `assets/fonts/Inter-Medium.ttf` | Inter Medium | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
| `assets/fonts/Inter-SemiBold.ttf` | Inter SemiBold | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono Regular | The JetBrains Mono Project Authors | OFL-1.1 | https://github.com/JetBrains/JetBrainsMono | UI monospace font; demo PDF code |
@ -215,7 +222,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `vendor/hayro-interpret/assets/FoxitSerifItalic.pfb` | FoxitSerifItalic (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/LAB.icc` | Lab abstract ICC profile | The Hayro Authors (generated with LittleCMS 2) | MIT OR Apache-2.0 | https://github.com/LaurenzV/hayro (hayro-interpret 0.7.0 assets/) | Lab colour space conversion (vendored hayro-interpret) |
| `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. |
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
@ -229,6 +236,8 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/app-icon/pdfcraft-1024.png` | PdfCraft app icon, 1024 px on Apple's icon grid | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs) |
| `assets/app-icon/pdfcraft.icns` | PdfCraft app icon (macOS .icns) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | macOS app bundle icon |
| `assets/app-icon/pdfcraft.ico` | PdfCraft app icon (Windows .ico, 16-256 px) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab) |
| `assets/app-icon/pdfcraft-document.svg` | PdfCraft PDF document icon (master vector) | Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition) | ISC AND (MIT OR Apache-2.0) | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md) | Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images |
| `assets/app-icon/pdfcraft-document.ico` | PdfCraft PDF document icon (Windows .ico, 16-256 px) | Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition) | ISC AND (MIT OR Apache-2.0) | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md) | Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 16 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 24 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 32 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
@ -267,6 +276,8 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py |
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
## Compiled in through dependencies (6)
@ -314,4 +325,4 @@ Not in this repository and never downloaded by it: compiled in only when the bui
| Input | Option | Title | Author | Licence | Source | Attribution | Used for |
|---|---|---|---|---|---|---|---|
| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); the web build embeds only BIZ UDPGothic Regular |
| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs never embeds (AGENTS.md §1.1) |

View File

@ -1,3 +1,14 @@
[[asset]]
path = "crates/ui-egui/src/i18n/el.tsv"
title = "Greek interface translation catalog"
author = "Camthalion77 and contributors"
source = "Contributor-original: clean-room translations of PdfCraft English UI labels"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "389982dcbb7f1ac29fb150c9af43f9529db6e59a091b9971f0401684847d8b53"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
#
@ -19,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "3066b34500d662ac6515806784a20b3b6f39d708dbaea26204995323c0a841f0"
sha256 = "d8ea23644d81476e3fd35c09780ab9b3ac8c9e28bed4e14e1cbc6493bb896d91"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@ -30,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "b8d2b959ae42cd0d1b823d958142f997891ce463e6075b37614536a51b63c190"
sha256 = "26dd0b0d697fd40db996fc102f3c7f2320bdeb6d6af38dbdde353bd5d940baad"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@ -41,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "e336657e76e498264691f4c55af84ae69af803bae2564e26f87a2bbad31fdf8e"
sha256 = "322b8cda21a2b8d157716182846619bd92bfb31263d1f8d13da7eaad365fbfd7"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@ -52,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "972ca1974635243d199fc6b3d2c77ab78ff5553674231f324bdbff9575035fd7"
sha256 = "21bf9c73b482eb987ddaf839b4a4aa8f9938d5aa10428e6d39beea254a95a0c0"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@ -63,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "a7e06aac931016658d143c0ce0246fcfa55bf8d88eee3b25162fcfa12eebd263"
sha256 = "fef35ae1771edf2e044e72aebb958bb4827ded57234207f96b6cb7a250fedb22"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@ -74,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "4120337665604b2799961383d44ab0e01413da5685b233c9051f4ab1a8234790"
sha256 = "d723622298b10400e9a1ba697cbebd831f5e0f7e5bbe4545b97f61e64db7535f"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@ -85,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0856ff8e37cb40b44f90ff122d638aca6b94c528994abd3b360f7ff01b74508e"
sha256 = "686ab9658b7ef9dd7f3af05ce73f99f1ab2a75f8357bad6f7e987d40a8730df9"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@ -96,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "1d891722c4bb69968eb3fca2c479ba9cf0d68fd6b2f0f3a5975f154246bdde0e"
sha256 = "90b94038699aa74aa48ffbda3e90a2255b65d0dbf081e8ecf20c5804940fc129"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@ -107,7 +118,18 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f0cdcfafe37d2be636ede1cef251fc5028ad233e020be8614727a676cc93283d"
sha256 = "5aa6475df91a3e82010aee198c2e29bb80328505bbd0240f3828e36a5d6dfdf3"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
title = "Polish interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "e6d306d9bf289fac2fe8fcfa6f9d0a51b650728e45669289d391be2031076692"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@ -118,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "3d95becdff6b37d8c1bb878578f6afcb0790f6519154e2dcef323344923abebe"
sha256 = "277f4995a87297a2fcc473df1feebdbdf3378fa460a7f85eb1a0a151cee40a2b"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@ -129,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "b1679b1fca5008f0a7d882204863ddfc9574195f2da0b1d8ef7d1b63b43d00bc"
sha256 = "01b32370cedce935b74925858a79ee691dc2a5560ba5eb758de2de1c243c528e"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@ -140,7 +162,18 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "5970a230e1bfaf9ee0e0eaa25af6d58017141a88a4859eb9fd7cd47282723d55"
sha256 = "f8ae97901c61dfdc0a844a23356173211bb052b3ff54128d7edc8097c535eee9"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
title = "Kazakh interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "7107ba77577794395451dd85355a86ef3e033a7f240b72414b877b9b43adb19e"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@ -151,7 +184,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "428c8729b83bf044cc2134f6fd4b130b38c9072a3c488889cf1adb6b3df1352c"
sha256 = "114b3d50531eb1682535c7a646e62914b0c9afaa5d5d1b9f66b94fd472e4e7b3"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@ -162,7 +195,40 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "98ade2903b213f16e9ee196aac9d35c54b923e20750cec84343d53f0ee655fed"
sha256 = "f56ec5f4cb7d1992041df0783f1cfd76c27d5d180c3f73c60b104e3fd02f600d"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
title = "Arabic interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "113fdf57e28fcff90d212fb468011bedebfb98ac100218d29b584f4de4c87912"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
title = "Turkish interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2b5edfe026578ed475f07f94f1d1ed4a3dac93a745c19209589936e2d67f6ec5"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
title = "Lithuanian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "fe58835b0538f470b463f078e1b4cdf78a8e2632056bc61c6946071478f39a64"
[[asset]]
path = "assets/icons/accessibility.svg"
@ -1385,6 +1451,17 @@ kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "fe917d6144a9aed9a5297cb28addb9e582ac591a8f1c498a55aecea9b5c1783e"
[[asset]]
path = "assets/icons/minimize-2.svg"
title = "Lucide icon \"minimize-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg)"
licence = "ISC"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "1150437d5a870321c59b7ebca2e1bc3f6227f0fa03dd10484ab384dfe5110739"
[[asset]]
path = "assets/icons/minus.svg"
title = "Lucide icon \"minus\""
@ -2086,7 +2163,7 @@ source = "https://github.com/rsms/inter"
licence = "OFL-1.1"
licence_file = "assets/fonts/OFL-Inter.txt"
kind = "font"
usage = "UI font, embedded in the app; demo PDF labels"
usage = "UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths"
sha256 = "40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82"
[[asset]]
@ -2288,15 +2365,16 @@ usage = "Default CMYK colour conversion (vendored hayro-interpret)"
sha256 = "73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0"
[[asset]]
path = "vendor/hayro-interpret/assets/LAB.icc"
title = "Lab abstract ICC profile"
author = "The Hayro Authors (generated with LittleCMS 2)"
source = "https://github.com/LaurenzV/hayro (hayro-interpret 0.7.0 assets/)"
path = "crates/fonts/src/std14_metrics.rs"
title = "Standard-14 font advance widths and encodings (as Rust tables)"
author = "Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors"
source = "ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash."
licence = "MIT OR Apache-2.0"
licence_file = "vendor/hayro-interpret/LICENSE-MIT"
licence_file = "LICENSE-MIT"
kind = "data"
usage = "Lab colour space conversion (vendored hayro-interpret)"
sha256 = "a6efc5c30ece826eba2a83ddf5aa7026289b0c5ffa90acb129bc59ab8d7f5c59"
adobe_data = true
usage = "Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree."
sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2"
[[bundled]]
crate = "epaint_default_fonts"
@ -2775,6 +2853,28 @@ kind = "icon"
usage = "Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab)"
sha256 = "56daaaae9ae5389794909570885809adf5e1d9183bef2d9669ed2f9bba3ed6f5"
[[asset]]
path = "assets/app-icon/pdfcraft-document.svg"
title = "PdfCraft PDF document icon (master vector)"
author = "Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md)"
licence = "ISC AND (MIT OR Apache-2.0)"
licence_file = "assets/app-icon/LICENSE-document.txt"
kind = "icon"
usage = "Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images"
sha256 = "8da8278c2b89362be0a960879d453ff879d7544d6d58e47d5389574e8dfff77b"
[[asset]]
path = "assets/app-icon/pdfcraft-document.ico"
title = "PdfCraft PDF document icon (Windows .ico, 16-256 px)"
author = "Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md)"
licence = "ISC AND (MIT OR Apache-2.0)"
licence_file = "assets/app-icon/LICENSE-document.txt"
kind = "icon"
usage = "Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations"
sha256 = "ba3f9128b368fc6d17d376cf62dbdbc22455451c5c8c4779210d7a2a7409b9e6"
[[asset]]
path = "assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 16 px"
@ -3212,13 +3312,13 @@ sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e"
[[build_input]]
name = "craft-fonts"
option = "CRAFT_FONTS_DIR"
title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho"
author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU)"
title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic"
author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic)"
source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml)"
licence = "OFL-1.1"
attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md"
kind = "font"
usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); the web build embeds only BIZ UDPGothic Regular"
usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs never embeds (AGENTS.md §1.1)"
[[asset]]
path = "crates/sign/tests/data/x509-rsa-sha1.pdf"
@ -3230,3 +3330,25 @@ licence_file = "LICENSE-MIT"
kind = "document"
usage = "Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format"
sha256 = "b77bfb21166f4bd043597e45a1ea28b9d9f4ba6df0142b3a0fc8b7f8e0fc111b"
[[asset]]
path = "assets/icons/monitor.svg"
title = "Lucide icon \"monitor\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg)"
licence = "ISC"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
title = "Slovene interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "9bbbd0d516f284b571ad6e2ed811f6bbdee283cea8483686f4aa939db38ea288"

View File

@ -3,8 +3,8 @@
PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Adobe Acrobat Pro parity. It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. It is the sibling of `../photocraft` (a Photoshop-class editor) and follows the same conventions.
## Start every session here
1. Read `plan/STATUS.md`: the current milestone, the next unchecked task and any blockers. `ROADMAP.md` holds the milestone estimates and progress; update its table and log at the end of every session.
- Read `ROADMAP.md` §Honest assessment and §Where we're lacking and where we're going before choosing work. They rank the gaps (own renderer, hardening, fidelity against Acrobat, editing existing content, Pro workflows, 1.0 polish); prefer them over new P2/P3 features, and keep both sections true when things change.
1. Read `plan/STATUS.md`: the current milestone, the next unchecked task and any blockers. `ROADMAP.md` is the one-page summary (stage, numbers, progress log) and `docs/roadmap.md` the milestone table; at the end of every session update the milestone row and add a line to the ROADMAP.md progress log.
- Read `docs/gaps.md` (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and `docs/target-app-parity.md` (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (`docs/` progress docs follow craftrules `standards/progress-docs.md`: status line, revision history).
- "Shipped" in `parity/` means "exists and tested", not "as good as Acrobat". Don't mark a feature shipped on a generic test, and say in its notes what is still missing.
2. Read that task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README of the crate you're touching.
3. Follow the **autonomous operation protocol** in `plan/execution-plan.md` §7 (orient → plan → implement + test → verify → record → commit). Don't stop to ask unless §7 lists the decision as the user's.
@ -31,8 +31,15 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- No `unsafe` (`unsafe_code = "forbid"`).
- Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`).
- **Rust only** in the product and build (`xtask`). No handwritten JS/TS.
- **Quality gates** before every commit: `cargo fmt --check`, `cargo clippy --workspace -- -D warnings`, `cargo test --workspace`, and the wasm check once `xtask ci` exists (M0).
- **Commits:** one task id per commit (e.g. `M1.4: xref stream reader`). Commit only green states. End messages with the attribution line required by the environment.
- **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening.
- Tests mirror the zooms: property → unit → crate integration → end-to-end.
- **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path.
- **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch.
- **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge.
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0).
- **Scope builds and tests to the crates you touched** — `cargo clippy -p <crate> -j 2 --all-targets` and `cargo test -p <crate> -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free.
- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `<type>/<slug>` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green.
- **`main` moves fast enough that PRs rot.** It took 396 commits in the three days to 2026-10-12, and most open PRs are conflicting at any given time. Rebase your own branch the same day `main` moves under it, and ask the author to rebase theirs — never rewrite a contributor's branch. Conflict risk tracks how *contested* the files are, not how large the change is: tooling, tests and docs are nearly free, while `crates/fonts`, `ui-egui` and the i18n catalogues are where several people collide at once.
## Running and looking at the app
- `cargo run -p pdfcraft -- <file.pdf>` opens the desktop app.

1251
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,44 +5,49 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.7.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/pdfcraft"
[workspace.dependencies]
# Shared frontend components; engines remain independent of egui.
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f03ec27ab2c405969428512178f746183cd55ca0" }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.5.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.5.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.5.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.5.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.5.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.5.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.5.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.5.0" }
pdfcraft-model = { path = "crates/model", version = "0.5.0" }
pdfcraft-content = { path = "crates/content", version = "0.5.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.5.0" }
pdfcraft-print = { path = "crates/print", version = "0.5.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.5.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.5.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.5.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.5.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.5.0" }
pdfcraft-js = { path = "crates/js", version = "0.5.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.5.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.5.0" }
pdfcraft-export = { path = "crates/export", version = "0.5.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.5.0" }
pdfcraft-create = { path = "crates/create", version = "0.5.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.5.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.5.0" }
pdfcraft-render = { path = "crates/render", version = "0.5.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.5.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.5.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.5.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.5.0", default-features = false }
pdfcraft-geom = { path = "crates/geom", version = "0.7.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.7.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.7.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.7.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.7.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.7.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.7.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.7.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.7.0" }
pdfcraft-model = { path = "crates/model", version = "0.7.0" }
pdfcraft-content = { path = "crates/content", version = "0.7.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.7.0" }
pdfcraft-print = { path = "crates/print", version = "0.7.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.7.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.7.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.7.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.7.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.7.0" }
pdfcraft-js = { path = "crates/js", version = "0.7.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.7.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.7.0" }
pdfcraft-export = { path = "crates/export", version = "0.7.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.7.0" }
pdfcraft-create = { path = "crates/create", version = "0.7.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.7.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.7.0" }
pdfcraft-render = { path = "crates/render", version = "0.7.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.7.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.7.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.7.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.7.0", default-features = false }
# pdf
hayro = "0.7.1"
@ -87,6 +92,11 @@ debug = "line-tables-only"
[profile.dev]
opt-level = 1
[profile.low-disk]
inherits = "dev"
debug = "line-tables-only"
incremental = false
[profile.release]
lto = "thin"
codegen-units = 1

14
NOTICE
View File

@ -13,23 +13,29 @@ This product bundles third-party material under its own licenses, listed below.
list (author, source, license) is in ATTRIBUTION.md.
- Lucide icons (assets/icons/*.svg) — ISC License. See assets/icons/LICENSE-lucide.txt.
The Windows PDF file icon (assets/app-icon/pdfcraft-document.svg and .ico) adapts Lucide's
file-text outline and combines it with contributor-original artwork. See
assets/app-icon/LICENSE-document.txt (ISC AND (MIT OR Apache-2.0)), packaged as
LICENSE-document.txt beside the Windows executables.
- Inter typeface (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
- JetBrains Mono typeface (assets/fonts/JetBrainsMono-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
- Dancing Script typeface (assets/fonts/DancingScript.ttf), Copyright 2016 The Dancing Script Project Authors — SIL Open Font License 1.1 (Reserved Font Name "Dancing Script"). See assets/fonts/OFL-DancingScript.txt.
- Japanese fonts from craft-fonts (https://github.com/storytold/craft-fonts), an optional build input
- Fonts from craft-fonts (https://github.com/storytold/craft-fonts), an optional build input
(CRAFT_FONTS_DIR) that is not part of this repository. Builds made with it, which includes every
official release, embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho,
BIZ UDMincho; the web build only BIZ UDPGothic Regular) under the SIL Open Font License 1.1. Those
packages carry each font's licence as OFL-<family>.txt.
packages carry each font's licence as OFL-<family>.txt. Builds made with the craft-fonts revision
pinned in .github/workflows/ also include Noto Sans Thai (in the web build too), Copyright 2022
The Noto Project Authors (https://github.com/notofonts/thai), under SIL OFL 1.1; its licence is
fonts/noto-sans-thai/OFL.txt in the craft-fonts build input.
- hayro (rendering, via crates.io) — Apache-2.0 OR MIT. Its `embed-fonts` feature bundles the Foxit
standard-14 Type 1 fonts under BSD-3-Clause ("Copyright 2014 PDFium Authors"), and hayro-cmap bundles
Adobe CMap resources under BSD-3-Clause.
- lopdf (bootstrap document inspection, via crates.io) — MIT.
- egui default fonts (epaint_default_fonts, via eframe) — Ubuntu Light (Ubuntu Font Licence 1.0),
Hack (MIT / Bitstream Vera licence), Noto Emoji (SIL OFL 1.1), emoji-icon-font (MIT).
- Colour profiles in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram) and
LAB.icc (generated by the hayro authors with LittleCMS 2; MIT OR Apache-2.0).
- Colour profile in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram).
Required notices:

View File

@ -133,6 +133,8 @@ Search the whole document as you type, step through matches with <kbd>⌘G</kbd>
## Navigate long documents
Right-click a PDF tab to close it, reopen the last closed file-backed tab, or reveal its file. Closing still asks about unsaved changes. Reopening reads the saved file from disk; it does not restore discarded edits. Reveal selects the file in Explorer or Finder. On Linux and FreeBSD it requests `org.freedesktop.FileManager1.ShowItems`, falling back to opening the containing directory with `xdg-open` when that service is unavailable. Reopen and Reveal are unavailable on the web; Reveal is also unavailable for pathless documents.
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
<table>
@ -314,8 +316,25 @@ pdfcraft-cli ui --control ~/.pdfcraft-control.json key key=K modifiers='["comman
pdfcraft-cli ui --control ~/.pdfcraft-control.json command id=comment.square # pick a tool, then draw:
pdfcraft-cli ui --control ~/.pdfcraft-control.json drag from='[400,300]' to='[600,420]'
pdfcraft-cli ui --control ~/.pdfcraft-control.json screenshot --out window.png
pdfcraft-cli ui --control ~/.pdfcraft-control.json resize width=1200 height=800
pdfcraft-cli ui --control ~/.pdfcraft-control.json focus label="Read"
pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect focused=true
pdfcraft-cli ui --control ~/.pdfcraft-control.json quit
```
`ui.dock` arranges the `tools` and `inspector` panels; `canvas` is the protected document view.
Each per-panel request requires a `panel` ID and an `operation`: `open`, `close`,
`activate`, `float` with `rect: [x, y, width, height]`, or `move` with `target` and
`zone: center|left|right|top|bottom`. For example,
`{"operation":"float","panel":"tools","rect":[100,100,320,440]}` floats Tools.
`ui.dock.reset` restores the default
arrangement. The control channel accepts these methods directly; `ui.command` also accepts
`{"id":"ui.dock","params":{...}}`. Saved preferences retain groups, active tabs, visibility
and floating geometry. Invalid requests return an error without changing the document or layout.
`resize` requests a native inner-window size in logical points (each dimension must be between 320 and 8192); the window manager may constrain it. `state` reports the observed `window.width`, `window.height` and `window.pixels_per_point`. `focus` accepts exactly one widget `id` or unique enabled, focusable `label`; subsequent `key` requests traverse or activate the actual widgets. `inspect` includes focusability and accepts a `focused` filter. `quit` takes no parameters: clean documents exit normally, while unsaved edits or typing open the existing Save/Don't save/Cancel prompt and return `quitting: false, needs_confirmation: true`. It never silently discards work. Window resizing and application quit are native-only.
---
## How it's built
@ -352,9 +371,12 @@ PdfCraft is a clean-room implementation. Its behaviour comes from the ISO 32000
## Get started
Building needs Rust 1.95 or later.
```sh
git clone https://github.com/storytold/pdfcraft
cd pdfcraft
cargo xtask models # fetch the OCR models Scan & OCR needs (release builds include them)
cargo run --release -p pdfcraft -- some.pdf # desktop app
cargo xtask demo-pdf # build the showcase PDF used in these screenshots
cargo xtask screenshots # regenerate every screenshot in this README
@ -395,16 +417,16 @@ Logs, environment variables and other development notes are in [docs/development
PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, at parity with Acrobat Pro.
**Where it stands (October 2026), honestly:** about half of Acrobat Pro's offline features are in (88% of the must-haves), but that is roughly a third of the work, because the hardest parts are still ahead.
**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most of Acrobat Pro's offline features exist in some form (57% of all tracked features, 94% of the must-haves). For everyday work (viewing, commenting, filling and signing forms, organizing pages) it is about 55% of the way to replacing Acrobat; against all of Acrobat Pro, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no Office-to-PDF, and Word export that loses layout.
- **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP.
- **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built.
- **Thin or missing:** reliable editing of existing text (especially CJK), OCR beyond Latin script, Office import/export, signature timestamps and long-term validation, PDF/A/X/UA preflight, XFA forms and localization.
- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms.
- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side.
**Next, in order:** our own renderer, hardening and a fidelity harness against Acrobat, editing existing content, then the Pro workflows (signatures, OCR, Office, preflight, XFA) and 1.0 polish.
**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish.
The honest assessment by area, what's lacking and where we're going are in **[ROADMAP.md](ROADMAP.md#honest-assessment-2026-10-05)**, with the full plan, progress and estimates.
The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md).
---
@ -428,14 +450,18 @@ The portable zip runs from any folder, a USB stick included. Its `portable.txt`
logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to
`%APPDATA%`; delete that file to use the normal per-user folders.
The MSI installs for all users and requires administrator privileges. For unattended deployment
without a desktop shortcut, run from an elevated terminal:
The MSI installs for all users and requires administrator privileges. It adds "Edit with PdfCraft"
to the right-click menu of PDF files in File Explorer (on Windows 11, under "Show more options"),
without changing which app opens PDFs by default. For unattended deployment without a desktop
shortcut, run from an elevated terminal:
```powershell
msiexec /i "pdfcraft-<ver>-windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0
```
Use the MSI for your architecture. Per-user installation overrides are not supported.
Add `INSTALLPDFCONTEXTMENU=0` to leave out the right-click menu entry. Pass both on upgrades too,
since each install applies its own choices. Use the MSI for your architecture. Per-user
installation overrides are not supported.
### macOS
@ -455,7 +481,9 @@ Use the MSI for your architecture. Per-user installation overrides are not suppo
| Tarball | `pdfcraft-<ver>-linux-x86_64.tar.gz` | `pdfcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
| Command-line tool | `pdfcraft-cli-<ver>-linux-x86_64.tar.gz` | `pdfcraft-cli-<ver>-linux-aarch64.tar.gz` | `pdfcraft-cli` alone (and its opt-in MCP server), for servers, CI and agents |
Every Linux build needs glibc 2.35 or newer (Ubuntu 22.04+, Debian 12+, Fedora 36+, RHEL 10).
RISC-V (riscv64) builds come as tarballs only: `pdfcraft-<ver>-linux-riscv64.tar.gz` and `pdfcraft-cli-<ver>-linux-riscv64.tar.gz`. They are cross-compiled and need glibc 2.39 or newer (Ubuntu 24.04+, Debian 13+).
Every other Linux build needs glibc 2.35 or newer (Ubuntu 22.04+, Debian 12+, Fedora 36+, RHEL 10).
### FreeBSD
@ -534,3 +562,14 @@ Forks and modified versions must remove them.
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
Tools and Inspector panels can be dragged into tab groups, split beside the PDF, or floated within the app window. Read and full-screen modes keep their existing focused document view. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
A center `move` can include `before` (panel ID or null to append) for tab order.
The equivalent `{"action": ...}` form accepts a serialized shared docking action;
this is also the form emitted by the renderer. Protected-canvas and app-specific panel
rules apply equally to both forms.

View File

@ -1,250 +1,351 @@
# PdfCraft roadmap
The milestones, current progress and time estimates to Acrobat Pro feature parity. Keep this up to date:
- **Every session:** update the progress column and add a line to the log.
- **Every milestone:** re-estimate.
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
Detailed task lists and acceptance tests are in `plan/execution-plan.md` (local-only). This file is the public summary.
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5 #1398: stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: cloudy comments stay when flatten cannot draw them, a refused text-box edit keeps its properties, arrow defaults stay off lines, a custom match stops at a separator, and the vertical-text redaction error no longer claims an overlap; #1317, #1327, #1302, #1315, #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; estimates unchanged · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1200 candidate: local GoToR links and confined automation; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2 short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request); earlier: M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
**What "parity" means here:** the offline feature set of Acrobat Pro, milestones M0–M14. It excludes Adobe's cloud services (Document Cloud storage, Adobe Sign, the Adobe AI Assistant). Those have no clean-room equivalent; PdfCraft's alternatives are local-first, plus opt-in providers (M13).
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI
Assistant) are out of scope; PdfCraft's alternatives are local-first plus opt-in providers.
## Estimate summary
| Document | Holds |
|---|---|
| [docs/target-app-parity.md](docs/target-app-parity.md) | The authoritative assessment: method, weights, every dimension and area, stage, calibration |
| [docs/gaps.md](docs/gaps.md) | Every known shortfall, ranked: the work list |
| [docs/roadmap.md](docs/roadmap.md) | Milestones M0–M14, current focus, upcoming milestones, risks |
| [docs/pdf-spec-parity.md](docs/pdf-spec-parity.md) | ISO 32000-2 coverage, annotations, actions, signatures, PDF/A/X/UA |
| [docs/file-format-parity.md](docs/file-format-parity.md) | Every format Acrobat reads or writes, create/export conversions, OCR |
| [docs/ui-parity.md](docs/ui-parity.md) | Tools, handles, shortcuts, panels, preferences |
| [docs/hardware-parity.md](docs/hardware-parity.md) | GPU, printers, scanners, smart cards, pen, platforms |
| [docs/localization-parity.md](docs/localization-parity.md) | Per-language status |
| [docs/architecture.md](docs/architecture.md) | How PdfCraft is built |
| `parity/acrobat-features.toml` | The machine-checked checklist (`cargo xtask parity`) |
The unit is **wall-clock hours of agent work** (Claude Opus 5.5 coding continuously; human review time not included).
**Keep it current:** every session, update the milestone row in [docs/roadmap.md](docs/roadmap.md)
and add a line to the [progress log](#progress-log); when a gap closes, remove it from
[docs/gaps.md](docs/gaps.md); re-measure fully when a major arc lands, at least monthly.
**Where we are (2026-10-07, measured by `cargo xtask parity` over 806 tracked Acrobat Pro features; 23 more are Adobe-cloud-only and out of scope):**
## Headline numbers
| Tier | Features | Shipped | Partial | Shipped % | Weighted % (partial = ½) |
|---|---|---|---|---|---|
| P0 (must-have for 1.0) | 251 | 222 | 25 | 88.4% | 93.4% |
| P1 | 326 | 174 | 38 | 53.4% | 59.2% |
| P2 | 186 | 14 | 7 | 7.5% | 9.4% |
| P3 | 43 | 1 | 0 | 2.3% | 2.3% |
| **All** | **806** | **411** | **70** | **51.0%** | **55.3%** |
**Effort-weighted parity: ≈ 30–35%.** Feature counts overstate progress: the remaining features include the hardest ones (our own renderer and font engine, editing existing text and reflow, OCR beyond Latin, XFA, PDF/A/X/UA preflight, Office export, long-term signature validation). Weighting each milestone by its estimated size gives about a third of the total work done. See **[Honest assessment](#honest-assessment-2026-10-05)** for what the numbers don't show.
**Observed throughput:** about 72k lines of kept, tested Rust (plus 97 agent tools and ≈ 600 tests) in roughly 65–75 agent-hours since 2026-09-30, about 1.0k lines per agent-hour, with corpus, oracle and visual checks. That is faster than the original plan assumed, so the hour estimates below are revised down from the first plan (2,000–4,000 h).
| Scenario | Remaining hours to full parity (M0–M14) | Calendar time |
| | Value | Kind |
|---|---|---|
| One Opus 5.5 agent, continuous | **≈ 600–1,100 h** | ≈ 4–7 weeks |
| 3–5 agents in parallel on separate crates | **≈ 250–450 h wall clock** | ≈ 2–3 weeks |
| With human review, integration and pauses | — | ≈ 2–4 months |
| **Feature breadth** | **≈ 67%** tier-weighted (56.9% unweighted: 423 shipped + 74 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` |
| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
| **Ready for real work**, full Acrobat Pro | **≈ 49%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) |
| **Ready for real work**, mainstream practitioner | **≈ 55%** (50–60%) | estimated ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
| **Ready for real work**, essentials user | **≈ 66%** (60–70%) | estimated ([method](docs/target-app-parity.md#essentials-user)) |
| Remaining to **beta** | ≈ 400–700 Opus 5.5 agent-hours | estimated |
| Remaining to **full parity** | ≈ 780–1,400 Opus 5.5 agent-hours (≈ 300–550 h wall clock with 3–5 agents) | estimated, calibrated on this repo's history |
**How the estimate is built:** each milestone's remaining fraction (table below) times its size, re-based on the measured rate. The long poles are M2 (own renderer, ≈ 150–250 h), M7 (editing existing text and images, ≈ 150–250 h), M10 OCR and Office export (≈ 100–150 h), M6 JavaScript engine (≈ 60–100 h), M11 PDF/A/X/UA preflight (≈ 80–140 h) and M12 XFA/compare (≈ 80–140 h). The last 5–10% (odd real-world files, pixel-level polish against Acrobat) costs about as much as a mid-sized milestone.
**Why alpha:** all six of Acrobat Pro's everyday workflows can be completed end to end on macOS
(the [alpha gate](docs/roadmap.md#alpha-gate)), and users praise them (7.6k stars, ≈ 217k downloads
of 0.4–0.5, switching reports such as #179). Two are only partial: editing (no CJK/RTL) and convert
and share (no in-app Office→PDF; Word export loses layout, #773; password-protected files open in
Acrobat since #836, #774). Those, unmeasured quality against Acrobat, the borrowed `hayro` renderer,
and 245 open issues keep it far from beta.
## Honest assessment (2026-10-05)
### Readiness by audience
Read this before choosing work. The feature table above counts what exists; this section says how solid it is.
| Audience | Ready | Opus 5.5 agent-hours to ≈ 95% | Work that dominates |
|---|---|---|---|
| Full Acrobat Pro (ready for real work) | ≈ 49% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions |
| Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, the bug backlog |
| Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, launch and GPU stability, simple text fixes, Word export layout, Chinese UI glyphs |
| Dimension | State | In one line |
Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#effort-and-calibration) (≈ 1k kept lines per agent-hour for new features, 3–10× slower for hardening and fidelity). The audiences are nested subsets, so essentials ≤ mainstream ≤ full.
## By dimension
| Dimension | Ready | Weight | Remaining (h) | Doc |
|---|---|---|---|---|
| Features (14 areas) | ≈ 49% depth · 67% breadth | 40% | 610–1,100 | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) |
| UI/UX fidelity | ≈ 55% | 10% | 30–60 | [ui-parity.md](docs/ui-parity.md) |
| File formats and conversion | ≈ 40% | 12% | 120–220 (within features) | [file-format-parity.md](docs/file-format-parity.md) |
| PDF specification and standards | ≈ 60% | 8% | 150–260 (within features) | [pdf-spec-parity.md](docs/pdf-spec-parity.md) |
| Hardware | ≈ 35% | 4% | 25–50 | [hardware-parity.md](docs/hardware-parity.md) |
| Localization | ≈ 59% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) |
| Performance | ≈ 40% | 6% | 25–45 | [gaps.md](docs/gaps.md) gap 13 |
| Stability | ≈ 50% | 8% | 45–80 | [gaps.md](docs/gaps.md) gap 6 |
| Platforms | ≈ 70% | 5% | 15–30 | [hardware-parity.md](docs/hardware-parity.md#platforms) |
| Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 (within features) | [gaps.md](docs/gaps.md) |
| AI features | ≈ 0% (in scope: local providers) | 1% | 20–40 (within features) | [gaps.md](docs/gaps.md) gap 23 |
| **Total (weighted sum)** | **≈ 49%** | 100% | **≈ 780–1,400** | |
## Features
| Area | Breadth (measured) | Ready (estimated) | Remaining (h) |
|---|---|---|---|
| A Core model and fidelity | 76.8% | 70% | 25–45 |
| B View and navigation | 62.5% | 55% | 110–190 |
| C Content editing | 53.3% | 30% | 100–180 |
| D Organize pages | 61.2% | 70% | 12–25 |
| E Comments and review | 77.7% | 70% | 12–25 |
| F Forms and JavaScript | 78.5% | 65% | 40–75 |
| G Protect, redact, sanitize | 58.5% | 55% | 15–30 |
| H Digital signatures | 62.2% | 45% | 40–70 |
| I Scan and OCR | 23.1% | 15% | 40–75 |
| J Create and export | 39.7% | 25% | 55–100 |
| K Optimize and standards | 34.5% | 20% | 65–115 |
| L Print | 39.7% | 35% | 30–55 |
| M Accessibility | 63.1% | 30% | 40–70 |
| N Automation, preferences, misc | 30.0% | 35% | 25–45 |
What's strong and missing in each: [target-app-parity.md](docs/target-app-parity.md#by-feature-area).
## Languages
Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). Detail and script notes:
[localization-parity.md](docs/localization-parity.md).
| Language | Code | Strings | Status |
|---|---|---|---|
| English | `en` | 100% | full |
| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) |
| Spanish | `es` | 95.5% | partial |
| Hindi | `hi` | 0% | none |
| Arabic | `ar` | 100% | partial (layout not mirrored) |
| French | `fr` | 99.3% | partial |
| Portuguese (Brazil) | `pt-br` | 99.4% | partial |
| Indonesian | `id` | 0% | none |
| Japanese | `ja` | 99.1% | partial |
| German | `de` | 99.3% | partial |
| Korean | `ko` | 0% | none |
| Vietnamese | `vi` | 0% | none |
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian
(94–100%) and Czech (menus only): **8 more languages**.
## Upcoming
Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md).
| Rank | Milestone | Estimate (h) |
|---|---|---|
| Feature count | 51.0% shipped (P0 88%, P1 53%, P2 8%, P3 2%) | A typical viewer, annotator, form-filler or page organizer is mostly covered |
| Effort | ≈ 30–35% | The remaining work is the hardest: M2 15%, M7 17%, M11 18%, M12 23%, M14 0% |
| Foundations | Weakest | Rendering is still the bootstrap `hayro`; the local #307 candidate reads inspector data lazily through COS with `lopdf` compatibility logic; vendored patches are still carried |
| Robustness | Early beta | Each 15-minute fuzz run found new out-of-memory crashes or hangs until 2026-10-05 |
| Acrobat fidelity | Unmeasured | No systematic side-by-side comparison with Acrobat, visual or behavioural |
| Product readiness | 0.2.1 | No code signing, performance budgets or keyboard-only operation yet; localization has its catalog system (English, partial Japanese) but few strings go through it |
| 0 | Convert and share: Create PDF from Office, Word export that keeps layout (#773) | 32–55 |
| 1 | Backlog and platform: crash and wrong-result reports, Chinese UI face (#826) | 50–90 |
| 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 |
| 3 | Editing existing content (M7) | 100–180 |
| 4 | OCR languages and Office export/import (M10) | 95–175 |
| 5 | Preflight and PDF/A/X/UA (M11) | 65–115 |
| 6 | Signatures for institutions: PKCS #11, timestamp servers, online LTV (M9) | 40–70 |
| 7 | Accessibility tagging (M12) | 40–70 |
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
**By area** (shipped share of each area's features):
## Progress log
| Area | Shipped | What's strong / what's missing |
|---|---|---|
| A Core | 75% | Parser, repair, encryption, incremental saves. Missing: lazy loading (`ByteSource`), own image codecs, PDF 2.0 extras, Arlington validation |
| F Forms | 71% | Filling, authoring, AF scripts, sandboxed JavaScript, data exchange. Missing: XFA, wider JavaScript object model |
| E Comments | 71% | Markup types with appearances, XFDF/FDF, summaries, calibrated 2D measuring (single-unit decimal scales). Missing: replace-text proposals, summary layouts, compound/fractional measurement formats |
| M Accessibility | 62% | Checker (all 32 rules). Missing: autotag, Tags/Order/Content panels, Reading Order tool, keyboard-only operation |
| B View | 55% | Shell, find, panels, tiles, web build. **Rendering is borrowed (`hayro`)**, so the 7 rendering P0s are only partial |
| D Organize | 55% | Pages, combine, split, bookmarks, labels. Missing: replace pages, transitions |
| G Protect | 54% | Passwords, permissions, redaction, sanitize. Missing: certificate security, redaction codes |
| H Sign | 48% | PAdES B-B signing and validation; macOS Keychain and Windows CNG certificate store signing. Missing: timestamps (B-T), LTV (DSS/OCSP/CRL), FieldMDP, smart cards, PKCS #11 |
| C Edit | 46% | Added text and images stay editable; header/footer/watermark. Missing: robust editing of existing text and images (fonts, subsets, reflow) |
| L Print | 36% | Acrobat-style sizing, n-up, booklet, CUPS. Missing: Windows and web printing, production options |
| J Create | 32% | From images, text, clipboard; Word/HTML/RTF export. Missing: Office import, Excel/PowerPoint export |
| N Misc | 27% | CLI, MCP, UI control channel, Action Wizard. Missing: AI providers, performance budgets |
| K Optimize | 26% | Reduce File Size, Optimizer. Missing: preflight, PDF/X/UA, transparency/fonts panels |
| I OCR | 19% | Searchable image for Latin script. Missing: other scripts and accents, editable-text output, deskew |
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged.
- **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged.
- **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged.
- **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged.
- **2026-10-12 (M3, #1364 candidate):** Absolute local GoToR links wait for Allow/Cancel before target-file access. Pending requests preserve their source and page/view destination; relative links retain their existing behavior. Headless `link_open` requires explicit `allow_absolute: true`, preserving existing root, symlink, file and password validation. Estimates unchanged.
**What "shipped" means, and doesn't.** A feature is shipped when it exists and at least one specific test covers it. 159 of 398 shipped features rest on exactly one test, and only about 5 cite an external oracle (`pdftotext`, `pdfsig`, OpenSSL, a corpus). Shipped does not mean "as good as Acrobat".
- **2026-10-12 (community, M7, inline Arabic ActualText):** A single-glyph lam-alef `/ActualText` span joins its surrounding word in Edit text instead of splitting it into separate lines. Pure RTL ligature text is put in visual order before line assembly and recovered to typing order with its neighbours. Multi-glyph and mixed-direction replacements retain their authoritative order. Synthetic regressions cover lam-alef variants, marks, two-byte glyph codes, nested spans, replacement and save/reopen. Estimates unchanged.
- **2026-10-12 (M5, #1398):** A stamp placed on a page with `/Rotate` reads upright as that page is displayed. Rubber stamps (including Sign Here), natural-size and explicit-rectangle images, and PDF-page stamps counterrotate their appearance; restyling a picture stamp keeps that correction, and a saved file reopens the same way. Estimates unchanged.
- **2026-10-12 (M10, file formats):** Create PDF reads BigTIFF scans; a malformed GIF header is no longer detected as an image only to fail conversion. file-format-parity.md now lists XDP data import/export for XFA forms (already shipped under `form.xfa-data`) separately from opening `.xdp` as a document. Estimates unchanged.
- **2026-10-12 (gap 6, #1317, #1327, #1302, #1315, #1340):** Flattening keeps a visible comment it cannot draw, such as a cloudy square with no stored appearance, and still draws a solid square, a hidden comment and a cloudy square that already has an appearance. A text box another program drew is left unchanged when a restyle or resize is refused. Make Current Properties Default on an arrow updates the Arrow tool and leaves plain lines alone. A custom text match that ends on a reconstructed word or line separator no longer includes the next glyph. The vertical-writing redaction refusal still fails closed, and its message no longer says the text was under the mark. Estimates unchanged.
- **2026-10-12 (M12, text-only auto-tag):** Automatically tag PDF now writes marked text objects, paragraph tags, a Document element and a parent tree for untagged text-only PDFs. The result is undoable and opens the Tags panel for review. Mixed paint, forms, annotations, existing tags and malformed streams are refused before mutation. Synthetic two-page engine and tool tests check the tree, checker, unchanged rendering and save/reopen. Richer content and role classification remain open. Estimates unchanged.
- **2026-10-12 (M7, #1316):** Replacing a line or paragraph no longer removes a distinct neighbour whose glyph box overlaps the edited line. A second drawing is removed only when it also sits on the same baseline, so fake-bold and invisible copies still go. Synthetic regressions cover 6 pt leading, the same word on the next line, ordinary spacing, a separate paragraph, same-position copies, and incremental and full save/reopen. Estimates unchanged.
- **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged.
- **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged.
- **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial.
- **2026-10-12 (M2, Lab Bradford follow-up):** Lab white-point adaptation scales Bradford LMS cone responses using LMS white-point ratios. Independent D50 and equal-energy neutral/chromatic render oracles, D65 identity, direct/Indexed consistency with asymmetric chromatic Range, and degenerate-cone fallback cover the correction. CalRGB and the merged Indexed/Range scaling retain their behavior. Estimates unchanged.
- **2026-10-12 (community, M14, recovery metadata):** A recovery snapshot whose metadata is missing, damaged or names another snapshot is no longer deleted when the folder is listed: it is offered for recovery without an original path (Save asks for a destination), and encrypted snapshots still ask for their password. Recovering one writes fresh metadata in place of the damaged file, and clearing the folder also removes metadata left without a snapshot. Before, a failed metadata write meant the next start deleted the finished snapshot. Synthetic regressions cover the failed write, missing, corrupt, mismatched and encrypted cases. Estimates unchanged.
- **2026-10-12 (M7, Arabic ActualText):** Add text records each shaped line in typing order with `/ActualText`, so copy and search preserve mixed Arabic, Latin, digits and brackets. Edit text keeps the logical order supplied by `/ActualText` instead of reversing Arabic or Hebrew a second time. Adjacent visual glyph runs retain their own bidi recovery. Font-independent tests cover Arabic marks, numbers, Latin words, reopen and complete replacement; automation checks saved editor text and unchanged rendering. Font fallback and bidi typing remain separate gaps; estimates unchanged.
- **2026-10-12 (M5, Fill & Sign text size):** The active Fill & Sign toolbar now chooses 4–300 pt for new text and dates; the in-place editor and saved annotation use that size, which persists in local settings. `fill_sign_add` accepts the same `font_size`. Feature and readiness estimates unchanged.
- **2026-10-12 (community, M3, Greek search):** Case-insensitive Find treats normal, final and capital sigma (`σ`, `ς`, `Σ`) as the same letter, so `κόσμος` and `ΚΌΣΜΟΣ` find each other. Case-sensitive and whole-word options, glyph ranges and match limits retain their behavior. Synthetic text-search regressions cover both directions. The tab-menu regression selects the platform's Reveal label (Finder on macOS), restoring the macOS test without changing the menu. Estimates unchanged.
- **2026-10-12 (M2, soft scans):** A shrunk, interpolated, axis-aligned image (a 300 dpi scan on a screen) is snapped to whole device pixels and shrunk once, not resampled a second time at a fractional scale and offset. On a synthetic scan page the error against an area-average reference fell from 3.4 to 1.6 grey levels at 96 dpi, and edge sharpness is within 2% of the reference (was 10% soft). Rotated, skewed, flipped and non-interpolated images are unchanged; downscaling of those is still open.
- **2026-10-12 (M14, Lithuanian localization):** Added Lithuanian as another interface and date language alongside the existing languages, with a 2,578-entry catalog and three-form plurals. Calendar, inspector and password-dialog regressions check localized labels and unchanged document values. The contributor has reviewed the translation as a native Lithuanian speaker. Estimates unchanged.
- **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged.
- **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged.
- **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged.
- **2026-10-12 (M7, private image crops):** Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged.
**Robustness, measured.** `main`'s CI was red for 30+ runs until 2026-10-04 (a clippy lint, Windows line endings, flaky perf tests). Once the nightly fuzz job could finish, three 15-minute runs found 7 out-of-memory crashes (one aborted on every platform) and 11 hangs, 7 of them in our own `cos` parser rather than `hayro`. All are fixed or in review, but expect more: keep the nightly fuzz job green and turn every finding into a synthetic test.
- **2026-10-12 (M12, accessibility finding locations):** Checker findings for figures, annotations and form widgets carry verified page bounds. Selecting one flashes its location; the headless check returns the same PDF-space rectangle. Malformed annotation rectangles remain page-only findings. Guided review and tagging estimates unchanged.
## Where we're lacking and where we're going
- **2026-10-12 (M14, gap 13, large-file memory):** Measured on synthetic 1 GiB and 100,000-page files. Edits let go of the previous working file (working bytes, display bytes, renderer) before writing the next, so a 1 GiB file peaks at two copies while editing instead of three (five edits: 3.1 → 2.1 GiB, 16 → 8.5 s). Saving still peaks at three; reading from disk on demand is blocked on the renderer (gap 2). Estimates unchanged.
- **2026-10-12 (M2, short text redraws):** Same-line redraws that restart a matching three-glyph prefix after more than one glyph-height of progress split before placement, so short doubled words no longer interleave. The existing redraw-position tolerance is reused; controls cover negative TJ movement, combining marks, RTL, CJK, vertical and turned text. Estimates unchanged.
- **2026-10-11 (M5, #1191):** Quick-bar colour changes now restyle the selected comment when it matches the active comment tool, while continuing to update that tool's default; added an accessible label to the swatch controls and a UI regression proving the saved style and undo step. Estimates unchanged.
- **2026-10-11 (M10.5, focused follow-up to #599):** Carry the architectural/ANSI/large ISO paper presets, current-page matching at 100%, and previewed PDF scale into the current print dialog and `doc_print`. Retains the #847/#1007 spooler and background discovery. Driver substitution, physical calibration, vector Windows printing and managed-endpoint restrictions remain separate gaps. Overall estimates unchanged.
The gaps that matter most, in priority order. Agents: prefer these over adding P2/P3 features, and check `parity/acrobat-features.toml` notes for the specifics of each.
- **2026-10-11 (contributor fork, M7):** Full matching font import completes source-subset glyphs; installed/file font selection, TrueType/OpenType-CFF mapping, disjoint range styles, marked paragraph boundaries and single-face contextual shaping/bidi added. Private Chinese keyboard edits on all four pages preserve source anchors and save/reopen renders. Curved clips exclude holes from hit tests; shared path scopes are isolated for image transforms without moving neighbouring paint. No font binary or private fixture is published; release font inputs and readiness estimates unchanged.
1. **Our own renderer (M2).** Replace `hayro` with the `model` crate, our font engine and the DisplayList devices (ADR-0004). It turns the 7 rendering P0 partials into shipped features and removes most vendored patches. The largest single lever.
2. **Hardening.** Keep the nightly fuzz job green; fix every crash and hang with a synthetic regression test; finish lazy loading so large files aren't read whole; set performance budgets (`misc.performance-budgets`).
3. **Measure fidelity against Acrobat.** Build a side-by-side harness (render, text, form behaviour) over synthetic fixtures (`plan/acrobat/`, clean-room rules apply). Until then, parity numbers count features, not quality.
4. **Editing existing content (M7).** Fonts, subsets, reflow and CJK. The most visible gap for Pro users.
5. **Pro workflows.** Signatures with timestamps and LTV (M9), OCR beyond Latin (M10), Office import/export (M10), preflight and PDF/A/X/UA (M11), XFA (M12).
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, German, Spanish, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Japanese, Russian, Ukrainian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Italian, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
- **2026-10-11 (M7, cropped image resize follow-up):** Resize from the visible (clipped) frame; an image's own clip moves with it (#1049) and shared clips stay fixed. Selected corner grips keep control over overlapping text; horizontal/vertical-only shrinking responds without freezing; one drag frame is previewed and one edit is applied on release. Synthetic pointer/tool regressions and private grow/shrink/save/reopen/undo rendering checks cover the regression. Shared clipping scopes containing unrelated painted content are refused. Estimates unchanged.
Decided against for now (owner, 2026-10-05): self-installing updates and an update check at start. Help ▸ Check for updates is manual only.
- **2026-10-11 (M7, cropped artwork selection):** Image/Form inventories now expose page/path-clipped visible bounds while retaining full placement geometry for transforms. Edit clicks prioritize text over background artwork; image frames stay within their own page and fixed clips remain fixed during movement. Engine/tool/UI regressions and an opt-in four-page private-file test cover direct access to 73 text regions and five cropped placements. Rectangular clips are exact; curved/complex clips use conservative bounds. Estimates unchanged.
## Milestones
- **2026-10-11 (M7, live text editor follow-up):** Reuse validated embedded sfnt source faces; adapt Mac format-6 subset cmaps through PDF Unicode mappings in memory without changing or installing source fonts. Preserve source baseline positions on entry. Show draft text and a single frame that grows/shrinks as rows are added or removed; single rows can grow toward the page edge. `text_edit_layout` exposes read-only draft bounds through the engine and automation. Licensed synthetic font/frame regressions and an opt-in all-page private-file check cover source font usage, stable positions, typing and deletion. Unsupported source programs, shaping and installed-font selection remain limited; estimates unchanged.
Hours are for a single agent (low–high). "Done" is the estimated fraction of that milestone's *acceptance criteria* that are met. It is not a count of lines of code.
- **2026-10-11 (M7, mixed-style regions follow-up):** Group adjacent source runs into visual rows and aligned rows into regions across subset fonts, colours, weights and text-matrix sizes. One inline TextEdit uses styled spans and source row breaks; rewriting preserves surviving source-run styles, source subset encodings and centered mixed-size title alignment. Added engine, automation and UI regressions for edit/delete, read-only preview, save/reopen and undo; privately verified the supplied real PDF's three-row title and six-row body, including selecting both in one editor without extra rows. `text_paragraphs` exposes `editor_text` and `rows`. Installed-font selection, exact editor glyphs, complex shaping and arbitrary multi-range style edits remain open. Readiness estimates unchanged.
| M | Milestone | Est. hours | Done | Remaining (h) | Notes |
|---|---|---|---|---|---|
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 92% | 2–4 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel. |
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; late browser startup downloads preserve the current document and Save target (#171). |
| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 17% | 210–420 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M8 | Security + redaction | 100–180 | 66% | 35–65 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize. Missing: certificate security, redaction codes and pattern locales, DCT re-encoding |
| M9 | Signatures (PAdES, validation) | 160–280 | 55% | 70–125 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 130–235 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, CUPS spooler, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; Create PDF from multiple files (PDFs, images and text, edited as pages in the grid). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, Windows/web printing |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 165–290 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 23% | 155–295 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 33–66 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Hungarian and Ukrainian catalogs have ≈1,800–2,100 entries each; Simplified Chinese, French, German, Hungarian and Ukrainian coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog; Ukrainian locales (`uk`, `uk_UA`) follow the Ukrainian one; its certificate and accessibility wording has a dictionary-checked follow-up. #307 (bounded large-PDF memory): share resource indexes and native parser metadata, inspect structure lazily, bound scan/full-save caches and raster storage, and reuse signature discovery safely. Three Linux trials retain full-save workflow RSS at 1.06 GiB (previously 3.69 GiB); rebased engine navigation uses 821 MiB (previously 997 MiB). With the RSS guard removed, the unmodified baseline was kernel OOM-killed in a 15 GiB container; rebased optimized inspection completed at 497.7 MiB in 1.78 s. Strict Clippy, 1,336 workspace tests and 30 WASM crate checks pass. The larger-host follow-up is closed. File-backed input, global memory budgets and Windows validation remain pending. Missing: more catalogs filled in, installers, performance budgets |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Spanish, French and Italian catalogs have ≈1,800 entries each; Simplified Chinese and French coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog; Italian locales (`it`, `it_IT`) the Italian one. Missing: more catalogs filled in, installers, performance budgets |
| | **Total (original plan sizing)** | **2,085–3,840** | **≈ 35%** | **≈ 1,350–2,500 at the planned rate; ≈ 600–1,100 at the measured rate** | |
- **2026-10-11 (M7, contributor fork):** Join compatible words across text objects and subset-font spaces; preserve source fonts that lack a space glyph using text positioning; recognize component colour operators. Inline editing uses one editor frame, resets its available width when switching blocks, keeps source line breaks, and renders a COS-clone background without the original glyphs, preserving unselected text advances. `page_render.hide_text_block` exposes the same read-only preview. Mixed-style paragraph grouping, installed-font selection and exact source-font editor rendering remain open.
**Overall progress: about 30–35% of the effort (51.0% of features shipped).** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen; rendering itself is still borrowed from `hayro`. See [Honest assessment](#honest-assessment-2026-10-05).
Newest first: the date, what moved, and the new overall estimate.
## Critical path
- **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged.
- **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged.
- 2026-10-11: Organize Pages shows an editable, synchronized physical page-range selection (#1092), with bounded validation and UI/operation regression coverage; overall estimate unchanged.
M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run in parallel across crates. The long poles are M7 (content editing), M9 (signatures) and M12 (XFA).
- **2026-10-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged.
- **2026-10-12 (M10.5, hosted CI):** Integrated current main, including its date-table formatting and static-storage fixes. The Arabic synthetic drawing regression now checks the original serialized-output golden with and without the optional font face: the face-dependent FontName and FontFamily are checked separately and normalized only in the fixture. The hosted FreeBSD fingerprint is reproduced by those two metadata fields alone. Drawing bytes and all other fields retain the original golden; no printing behavior or readiness estimate changes.
- **2026-10-11 (M3, animation effects):** PdfCraft follows Windows' Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects, and Preferences has its own Animation effects: Use system setting (the default), On or Off. With animation effects off, a wheel notch scrolls its whole distance, and Ctrl with the wheel zooms its whole step, in the frame it arrives. Both were eased over about a quarter of a second whatever the setting: egui spreads a coarse wheel step over later frames and has no switch for it, so `crates/ui-egui/src/motion.rs` reads each frame's wheel input the way egui does, minus the easing, and hands that to the scroll areas and the views (a test sums what egui eases and compares it with what is read at once, and fails if egui stops easing). egui's own animations (menus, sections, scrolling to a row) take no time, the chrome's hover and press washes are simply there, and the progress card's bar steps without its sweep. The desktop app asks WinRT's `UISettings.AnimationsEnabled` at launch and every two seconds of use, so a change applies without a restart. How far a notch scrolls or zooms is unchanged. The preference set to On or Off decides all of this whatever the system says; it is saved, set as `animation_effects` (`system`, `on`, `off`) on the control channel, and translated in all 20 catalogs. Not done: macOS (Reduce motion), Linux (GNOME's animations switch) and the browser (`prefers-reduced-motion`) aren't asked yet, so Use system setting animates there as before; Off works everywhere. Estimates unchanged.
- **2026-10-11 (M10, gap 4):** The web build prints. Its Print dialog offers "Browser's print dialog" (the default there), which reaches every printer the operating system knows. PdfCraft draws the print-ready PDF's sheets itself with its own renderer, a sheet a frame because the web build has no threads: 300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000, at most 64 megapixels a sheet and 1 GiB of pictures a job. It then prints them from a hidden iframe, one sheet per printed page with no margin. A landscape sheet among portrait ones is turned onto the paper, as a driver does, because Chromium prints every page at the default `@page` size. Copies, two-sided and colour are set in the browser's dialog, so the dialog greys them out. If the browser refuses to print (a sandboxed frame, for example), the print-ready PDF downloads instead. Verified in headless Chromium: the right sheets at 300 dpi, the landscape sheet turned, no blank trailing page, and the download fallback. Not yet tested in Firefox or Safari. Gap 4 closed; rank 1 is 65–120 → 50–90 h; other estimates unchanged.
- **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows CI passes, including paused-queue submission and cleanup; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change.
- **2026-10-11 (M10, #1028):** Recognize Text can read pages that already have text: "Also recognize pages that already have text" in the dialog (remembered with the other choices, and used by Recognize text in multiple files) and `skip_text_pages: false` on `ocr_recognize` and, newly, `ocr_recognize_files`. A picture on such a page becomes searchable, and a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out, so nothing is found twice; `ocr_recognize` reports how many were left out as `already_on_page`. Before, the option existed only for agents and added the page's text a second time. The default still skips pages with text. Synthetic tests (a picture of a sentence plus real text) cover the engine, the tool and the dialog, and fail with the filter turned off. Still missing: reading only the picture areas (the whole page is read, as for a scan). Separately, `recognize_text_makes_a_scanned_page_searchable` fails on main wherever the models are installed (the model reads "quick" as "auick"). Estimates unchanged.
- **2026-10-11 (M2, page-label prefixes):** The page-label readers bound the total size of decoded prefixes. Each range decodes its own prefix, and ranges may share one prefix object, so a small file could ask for a copy per range: within the 200,000-entry tree limit, about 200 MiB of copies of one 1,024-byte prefix. Inspection and Organize now refuse a tree whose decoded prefixes pass 4 MiB in total (`MAX_PREFIX_TOTAL_BYTES`), before any label is made, as the other tree limits do; relabelling such a tree changes nothing. Synthetic tests share one 1,000-byte prefix across 5,000 ranges and keep exactly 4 MiB of prefixes readable. Overall estimate unchanged.
- **2026-10-11 (UI, checkboxes and radio buttons):** Checkboxes no longer look like radio buttons, and on the light theme neither is invisible. With the theme's 6 px widget radius egui drew a checkbox's 14 px box as a circle, so View ▸ Page display's Continuous scrolling and Show cover page read as two more layout choices; on the light theme an unticked box or an unselected radio button was white on white with no edge. Every checkbox (81) and radio button (79) now goes through `widgets::checkbox` / `widgets::radio` / `widgets::radio_value`: a 1 px outline (darker on hover), the plain fill on the light theme as dialogs already had, and for checkboxes a rounded-square box (3 px), keeping what the caller set (dialogs, the search panel). `clippy.toml` disallows `ui.checkbox`, `ui.radio`, `ui.radio_value`, `egui::Checkbox` and `egui::RadioButton` so new ones use them too. Estimates unchanged.
- **2026-10-11 (M13, #971):** `text_find` now bounds each page's temporary match-range list to the remaining result limit plus one, which is enough to detect and return a continuation without allocating every match on a dense page. Added limited-search coverage; the existing end-to-end continuation test passes. The text cache byte budget and per-page extraction warnings remain open.
- **2026-10-11 (M7, #594):** Ordinary paragraph body drags now use the existing source-preserving object translation path; edge drags still rewrap. An in-progress drag is discarded if the document generation changes, and added-content gestures keep their own edit handler. Added real-shell source regressions for overlapping and identical labels, history, save/reopen, partial overlap, kerning, rotation/crop/UserUnit and atomic refusal. Linux validation passed: focused UI/engine regressions, exact-base source-loss failures, native drag/undo/redo/save/reopen with independent Poppler renders, and all eight CI gates. Text replacement and resizing over unrelated overlapping content remain outside this repair. Estimates unchanged.
## Risks most likely to push estimates up
- **2026-10-10 (community, M7, Thai Add Text):** Optional craft-fonts Thai face (Noto Sans Thai) for the UI and shaped Add Text through the engine, CLI and MCP. Stacked marks and Sara Am are kept through Type 3 clusters with ToUnicode; regular style, and wrapping at cluster boundaries (no dictionary word segmentation). The craft-fonts pin for CI and releases moves to the revision with the Thai face. Forms and Fill & Sign are outside this change. Estimates unchanged.
- **2026-10-11 (#995, #1077, permissions password after opening):** A PDF with only a permissions password opens without asking, restricted, and nothing let the user enter that password afterwards: the notice and the hint pointed at reopening with it. Now the security notice and 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. `Session::unlock` upgrades the open document in place (owner and user passwords unlock the same file key, so nothing is re-read), unlocks the undo and redo states and reopens saves as owner; it is not an edit and doesn't mark the document unsaved. New command `protect.unlock`; the new strings are in all 15 full catalogs. Tests cover crypt, cos, engine (undo, redo, save, separate open password) and the UI. Still missing: an automation tool for it. Estimates unchanged.
- **2026-10-11 (M14, #307 Plan 3):** Engine open and full refresh use one page/content traversal to populate marks and added content. The independent public scans remain available; the combined scanner borrows direct objects and holds referenced objects through `Arc<Object>` instead of cloning scan-only `/Contents` arrays or `/PCAdded` dictionaries. Differential tests cover an indirect `/PCAdded` dictionary as well as ordering, malformed/direct/indirect/repeated entries and `/UserUnit`. Exact `bbbe600`→final release CLI measurements used 10 alternating pairs: wall-time medians were 183.645→174.440 ms on 10,000 marker-free pages (10/10 wins), 33.587→32.752 ms on 1,500 mixed pages (8/10), and 24.331→20.133 ms on the 100,000-entry array stress fixture (10/10). The stress fixture's maximum-RSS median fell 28,655,616→19,038,208 B; peak-footprint medians were 14,747,136→14,673,408 B. From P0, the follow-up stress run was 20.886→20.201 ms (10/10), with RSS 23,953,408→19,087,360 B and peak footprint 14,706,144→14,697,952 B. `doc_open` stdout hashes matched every pair. The ancillary `organize` binding preserves the prior `prune_legacy` update condition. Estimates unchanged.
- **2026-10-11 (M4, Organize overview):** Opening Organize pages from All tools, Home, the tool palette or `ui.set tool=organize` opens the thumbnail grid directly. Checkboxes toggle individual pages without modifier keys; page controls rotate either way or delete one page, while the toolbar acts on the selection. Portrait and landscape previews retain their aspect ratio. UI and control regressions cover entry, selection, single-page versus batch actions, undo, permissions and retaining the last page. Milestone and effort estimates unchanged.
- Fidelity of text editing: fonts, subsets, reflow.
- XFA dynamic layout.
- Real-world signature chains and revocation checks.
- The rendering long tail: Type3 fonts, broken fonts, shadings.
- Correctness of PDF/A and PDF/UA conversion.
- CPU rendering performance on the web.
- Hostile input: every fuzz run so far has found new crashes or hangs.
- Unmeasured fidelity: without an Acrobat comparison harness, quality gaps surface as user reports.
- **2026-10-11 (M5, partial comment imports):** Replacing an editable named comment through XFDF or FDF (including an indirect name in the source PDF) preserves its object identity, so existing replies and review statuses remain attached after saving, reopening and exporting again. Replaced pop-ups are still removed. Locked comments, redaction marks and their pop-ups stay unchanged, including when their names are indirect; imported redactions remain visible. Synthetic round-trip tests cover both formats, incremental and full saves; milestone and effort estimates unchanged.
## Log
- **2026-10-10 (M9, signature graphics):** A visible digital 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 strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged.
- **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %).
- **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted.
- **2026-10-11 (M10, #773 integration):** Merged current upstream updates into the Word export fix and adopted the shared Windows symlink test setup. Word layout scope and estimates unchanged.
- **2026-10-11 (M10, #773):** Word export no longer invents visible borders for tables inferred from text alignment. Rotated text, and pages where text overlaps an image or a watermark-sized text block, use positioned, editable shapes so invoice watermarks do not reserve body space (pages whose logo or title sits clear of the text keep flowing paragraphs, tables and inline images); source pages have separate sizes and section breaks. Synthetic export and automation regressions cover the invoice failure. Exact font matching, vector rules and general layout fidelity remain partial; estimates unchanged.
- **2026-10-11 (docs, closed issues):** The progress docs caught up with issues closed on 2026-10-10: encrypted output opens in Acrobat (#774, #836; gap 1 closed, rank 0 is 32–55 h), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), and the #799–#821 batch of wrong-result fixes including the #816 panic. Scores and readiness not re-judged; overall estimate unchanged.
- **2026-10-11 (M7, watermark placement):** A watermark can be moved from the page centre by a horizontal and a vertical offset in points: Add/Update Watermark has an "Offset from centre" row (the preview follows it, for text and for pictures) and `doc_watermark` takes `offset_x` and `offset_y`. A non-finite offset is refused instead of written into the page. `edit.watermark-appearance` stays partial: no offset from the page edge or in page units, no print/screen-only variants, no repeat. Image and PDF watermarks, already shipped, no longer appear as missing. The overall estimate is unchanged.
- **2026-10-11 (M12, XFA script time limits):** An XFA script's time limit (1 s for scripts that run unasked, on open and on every change) counted the engine's start-up too: the script's thread, a new context and, for the first script in a process, the engine's own set-up (≈ 60–170 ms when idle). On a busy machine a trivial initialize script could pass the limit, be abandoned and turn the form's scripts off; that made `xfa_buttons_run_their_scripts_in_the_app` and `messages_from_scripts_run_on_open_show_right_away` flaky (under full CPU load 25 of 40 runs of the test binary failed, with "ran longer than 1.0 s and was abandoned" in the console). The limit now runs from when the engine is ready; start-up has its own 10 s cap, so nothing waits forever, and a trivial warm-up script takes the first-in-process set-up. Under the same load none of 40 runs fails, and runaway scripts are abandoned as before. The two UI tests print the script console when they fail. Overall unchanged.
- **2026-10-11 (M4, #569):** Combine files takes images and text files as well as PDFs. PNG, JPEG, TIFF, GIF, BMP, JPEG 2000 and .txt files become PDFs as they are added (Add files…, drops, folder scans), so each is an ordinary row with its pages, thumbnails and page range; a file that can't be read (a damaged image or PDF, a type that can't be converted) stays in the list with why in its Warnings, and Combine asks before leaving such files out (Cancel changes nothing); password-protected files still have to be unlocked first. `doc_combine` accepts the same types, and a converted file's bookmark drops its extension. A PNG whose header claims more than 2 GiB of pixels is now refused before its buffer is allocated (the png crate's limits don't cover it), as TIFF already was. Office documents and HTML are still not converted. Overall estimate unchanged.
- **2026-10-11 (M3, Windows renderer startup):** Renderer-creation GPU errors return through the existing scoped handler so Auto startup can use the existing OpenGL fallback. A real-device invalid/valid pipeline regression covers error propagation and device reuse; the independent Windows hostile-argument fixture reports unavailable symbolic-link privileges. The underlying x86 DX12 compiler error remains unresolved; see `docs/windows-renderer-startup.md`. Milestone and effort estimates unchanged.
- **2026-10-11 (M6):** Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. Estimates unchanged.
- **2026-10-11 (M6):** Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Estimates unchanged.
- **2026-10-11 (M13):** CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. Estimates unchanged.
- **2026-10-11 (M14, #307, Plan 2):** Forms now return terminal fields and the page-widget adoption count from one enumeration, and engine open reuses that result while retaining XFA script-button marking. The 12,000-page/no-form PDF (1,442,052 bytes) and 2,500-orphan-widget PDF (673,432 bytes) produce byte-identical `doc_open`/`doc_info` outputs against upstream `33c14e07cfd742a82bf03baa6b103c01008fa68c`, including the orphan repair note. After one warm-up per binary and three interleaved base/candidate rounds, median open times were 0.16/0.15 s and 0.08/0.07 s, with median peak RSS 62.69/59.98 MiB and 33.28/32.20 MiB. Workspace tests, Clippy, formatting, focused forms/engine/organize tests, and the WASM check pass. A behavior-preserving local binding in `organize` also lets the workspace Clippy gate pass. No estimate change.
- **2026-10-11 (M14, Traditional Chinese semantics):** Corrected accessibility pass/fail labels, the inverted script-accessibility rule, Taiwan row/column terminology and form choice/read-only labels; added the missing table-row explanation. Catalog regressions cover the rule metadata and preserve field names. Milestone and effort estimates unchanged.
- **2026-10-11 (M14, no silent `?`):** Text PdfCraft's fonts can't draw (Chinese, Greek, Czech's ř in Helvetica) is never written into a PDF as `?` without a word. Form values and button captions, text boxes, callouts, Fill & Sign text, measurement labels and dynamic stamps' by-lines are kept as typed, as Acrobat keeps them: the appearance draws `?` there, the annotation is marked (`PCUndrawn`), and the edit says so (the interface shows a translated notice; tools return `warnings`). A form also sets `/NeedAppearances`, so other viewers redraw the field from its value (not in a PDF/A document, which forbids it); a restyle doesn't replace a comment another program drew with those characters. Where `?` would be permanent, the edit is refused with the character and what to do instead: flattening what PdfCraft drew with `?`, redaction overlay text (also checked before anything is redacted), typed signatures through `fill_sign_add`, visible signature details, and new documents from text (with the line). Reports PdfCraft writes from documents (comment summaries, compare reports) write such characters as `[U+XXXX]` and say so; a dynamic XFA form's own text is laid out and reported. One end-to-end test drives every path through the tools. Not yet: drawing those characters (embedding a font that has them). Overall estimate unchanged.
- **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). 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, so 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; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %.
- **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged.
- **2026-10-10 (M5, shape fill colour, #686):** Rectangles, ovals, polygons and clouds can be filled. The comment quick bar has a Fill colour control for those tools ("No fill" or a swatch; it persists with the other per-tool styles), Comment Properties ▸ Appearance has a Fill colour row (one undo step with the other changes, carried over by Make Current Properties Default), and `comment_edit` takes `fill` (`"none"` removes it). `pdfcraft-annot::set_fill` writes or removes `/IC` and redraws the appearance; lines, ink and locked comments refuse it. Still missing: a custom colour picker (only the comment palette), and a separate fill for line endings.
- **2026-10-10 (M10, #645):** Export all images and Save image as keep transparency. A JPEG with a soft mask (the usual cut-out photo) was written as the bare JPEG, and soft masks of another size than the image, JPEG-compressed soft masks, stencil `/Mask` images and colour-key `/Mask` arrays were dropped, so cut-outs came out on solid white or black. These images are now PNG with the mask as alpha (soft masks scaled to the image, `/Matte` un-blended); JPEGs without transparency are still written byte for byte. A negative image size no longer overflows in Save image as. JPX/JBIG2/CCITT masks are still left out. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M12, XFA rows and areas):** Dynamic XFA forms lay out a repeating subform inside a top-to-bottom container in positioned content (a table in an area) with all its instances, drawn and measured, instead of one; and areas no longer appear in data paths (`pdfcraft_xfa::data_path`), since they are not data scopes. Before, a form whose fields sit in an area read none of the values Adobe's viewers saved, wrote what was filled here under a new element named after the area, which Adobe's viewers would most likely not read, and a FormCalc total looping over the table's rows failed on the rows that were missing. Layout, the script tree, reading and writing the data and the engine's value and row operations share the one path rule. Synthetic tests cover the layout, the path rule and the whole round trip (rows, Adobe-written values, a FormCalc total over all rows, the written data, reopening). Still open for such forms: XFA script objects (`<variables><script>`), which aren't supported. Overall estimate unchanged (about 30–35%).
- **2026-10-11 (M10, Windows printing follow-up to #847):** Windows print jobs print each sheet at 100% on the driver's paper closest to the sheet's size, landscape for wide sheets; they had been shrunk into .NET's default 1-inch margins (about 76% on Letter) and not rotated. Sheets are capped at about 64 megapixels so a poster-sized sheet can't exhaust memory. PowerShell runs without a console window, and the Print dialog lists printers and spools jobs off the UI thread (the desktop app's `printer_source`), so neither Ctrl+P nor a long job freezes the app. Kittest regression for the background printer list; the Windows rendering path is still checked only by the Windows CI job. Estimates unchanged.
- **2026-10-10 (M3, System appearance completion, #446/#881):** The Linux portal introduced by #881 now uses the standard numeric appearance setting and event-driven updates, with idle-window wakeups for initial and changed results. When that setting states no preference it falls back to the GNOME schema's `color-scheme`, which XFCE's portal answers instead. Native windows retain inherited appearance and both egui style branches use the selected palette. Manual choices and the Light default remain; unknown System appearance resolves to Light. Controlled protocol and UI checks cover the remaining gaps; the supplied EndeavourOS/GNOME report has not been reproduced on that desktop. Estimates unchanged.
- **2026-10-10 (M9, #179 follow-up):** A Windows store certificate that can't sign is no longer left out of the Sign dialog silently: `windows::list` names it with the reason and Windows error code (no private key, a P-521/Brainpool/Ed25519 key, a key CNG can't open), the picker shows it greyed and unselectable, and `sign_windows_ids` returns it under `unusable`. A key that needs a permission prompt to open is listed instead of skipped. Reported by a user whose CA-issued identity showed in Acrobat but not here; a plain .p12 import into the CNG and legacy Microsoft providers was verified to list, so the next report will carry the reason. Store test adds a key-less certificate; overall estimate unchanged (about 30–35%).
- **2026-10-10 (M10.2, JPEG scanning follow-up to #729):** Skip marker fill bytes and keep reading JFIF, APP14 and ICC metadata after the frame header, stopping before scan data or at EOI. Invalid segment lengths return errors; damaged or mismatched ICC profiles retain #729's device-space fallback. Synthetic regression cases added but not run at the contributor's request. Estimates unchanged.
- **2026-10-10 (M13, desktop action outputs):** Desktop Action Wizard keeps every result when input basenames repeat, or when the destination already has a result from an earlier run. It preserves the original filename and extension for a free name and adds numbered suffixes for occupied names; the bounded search returns an error rather than reusing an occupied path. A real-shell regression drives Start twice and reopens the three-page and one-page results, checking that the original inputs and earlier output remain intact. This covers the desktop writer separately from the CLI/MCP action_run repair in #877. Milestone and effort estimates unchanged.
- **2026-10-10 (L Print, #756):** Windows prints. The Print dialog lists the system's printers and its default from `Win32_Printer` (through PowerShell's CIM; `wmic` is gone from Windows 11), and Print spools the print-ready PDF: the in-box Windows.Data.Pdf renders each sheet at the printer's own resolution and System.Drawing.Printing carries copies, collation, duplex and colour as the job's settings — both reached through `powershell`, so the Rust code touches no Win32 API and the machine needs no PDF reader. This closes #756 and the "printer not showing" duplicates #160, #244, #293, #465 and #644, using the CIM approach community-tested in #452; the tests render the job's sheets through the whole WinRT path on CI (dry run, no printer needed) and, where a printer can be added, catch a real job in its paused queue. Web printing is still missing; milestone and effort estimates are unchanged.
- **2026-10-10 (M3, #740):** Column select, as in Acrobat: holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle, with the Select tool, the text markup tools and Redact; Edit ▸ Column select does it with a plain drag, for Linux desktops whose window manager takes Alt-drag (Xfce, Cinnamon, MATE, KDE Plasma 5 by default). Copy gives rows top to bottom with tab-separated cells (right to left in right-to-left rows, reading order for vertical text); `text_extract` takes the same rectangle as `rect`. The new label is in all 15 catalogs. Also repaired `parity/acrobat-features.toml`, which #650 had left unparseable (duplicate keys on `form.field-list`). Still missing: column selection across pages and ⇧-click extension. Estimates unchanged.
- **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the standard's additive weighted sum over the dimensions (written weights), 47% → 49%: method aligned with the standard, no new evidence. Beta ≈ 26 points, ≈ 400–700 h.
- **2026-10-10 (progress docs, gate consistency check):** Re-judged the convert-and-share row with the rule applied across all Crafting Apps (a workflow blocks only when it can't be completed at all; lossy exchange and missing sub-cases are beta items, as for FilmCraft's .prproj or VectorCraft's .ai). Unencrypted sharing works, encryption interop (#774) and Office→PDF are sub-cases, Word export is lossy rather than absent: partial, not blocking. **Stage pre-alpha → alpha**; numbers unchanged (47%, mainstream 55%; beta ≈ 400–700 h). Added an essentials-user score: ≈ 66% for someone who only opens, reads, comments, fills, signs, prints and rearranges pages.
- **2026-10-10 (progress docs, second look and alpha gate):** At the owner's request, after practitioners praised PdfCraft, re-examined the numbers. User sentiment: 7,595 stars, 177k + 39k downloads of 0.4.0/0.5.0, 23 user issues with explicit praise and switching reports (#179, #232, #273), issue themes concentrated in core paths (launch/GPU, rendering, editing, CJK, signing, printing). The checklist is not niche-dominated (497 mainstream vs 311 specialist rows; P0 95% / 91%). The first pass's 9-point cross-cutting deduction double-counted Windows printing, interop and glyphs: **ready for real work 40% → 47%**, and **≈ 55% for the mainstream practitioner** (new). The new core-workflow gate (craftrules progress-docs) fails on convert and share (#774, no Office import, #773): **stage alpha → pre-alpha**, ≈ 40–70 h from alpha; beta still ≈ 400–700 h.
- **2026-10-10 (progress docs, full re-measure):** Re-measured against Acrobat Pro 26.002.21931 (installed bundle inspected: document types, localizations, plug-in and framework names). Checklist: 423 shipped + 74 partial of 808 (56.9%; P0 94.2%), ≈ 67% tier-weighted. Ready for real work ≈ 40%; stage **alpha**; ≈ 400–700 h to beta, ≈ 780–1,400 h to full parity (up from 600–1,100 h: the first public releases surfaced Acrobat interop of encrypted output, Windows printing, Chinese UI glyphs, Word-export layout and a 245-issue backlog). This file restructured to the craftrules progress-docs standard; the honest assessment moved to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, the milestones (duplicated rows merged) to `docs/roadmap.md`; new `docs/architecture.md`, `docs/localization-parity.md`, `docs/file-format-parity.md`, `docs/pdf-spec-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`.
- **2026-10-10 (M8, redact word lists):** 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, 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). `redact_mark` takes `words` and reports how many marks each made, so an agent sees which found nothing. Tests cover the list parsing, the tool end to end (mark, undo, apply, invalid lists) and the dialog with an imported list; the new labels are translated in all 13 UI catalogs. `protect.redact-word-lists` is shipped; whole-word and case-sensitive matching and exporting the list are still missing. Estimates unchanged (about 30–35%).
- **2026-10-10 (M4, 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: H1–H6 through the role map, a plain H by its section nesting, nested by level under a new first "Untitled" bookmark as in Acrobat, each linked to its structure element (/SE). Titles come from /ActualText, the marked text on the page (decoded through the font's ToUnicode or encoding), /Alt or /T. The walk is bounded (depth, visits, heading and title caps, cycle-safe), an untagged document gets a clear error, and the edit is one undo step. Tests cover role maps, nesting, ToUnicode text, a self-referencing heading, the tool, undo and the panel. Still missing: Acrobat's element-type choice and destinations at the heading's position. The M4 row no longer lists Replace pages as missing (it shipped earlier). Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M9, signing encrypted documents):** Digital signatures, certification and document timestamps work on password-protected PDFs instead of failing with "not supported: signing encrypted documents". `pdfcraft-cos` no longer encrypts or decrypts a signature dictionary's `/Contents` (§7.6.2; by `/Type /Sig|/DocTimeStamp` or `/Contents` + `/ByteRange`), so the placeholder is patched after hashing as in a plain file and existing signatures in encrypted files read their real CMS (which also fixes their DSS `/VRI` keys and keeps them intact in a full rewrite). Opened with the user password, the signer applies the document's permissions (fill-forms for existing fields and timestamps, annotate + modify for new fields, modify for certifying) and names the permissions password when it refuses. Synthetic tests cover all four algorithms, counter-signing, tampering, permissions and the automation tools. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M9, signer certificate usage):** A trusted signer's certificate must also allow signing documents (RFC 5280 §4.2.1.3, §4.2.1.12, §4.2). A key usage without digitalSignature or nonRepudiation, an extended key usage with no document-signing purpose (any, emailProtection, codeSigning, RFC 9336 documentSigning, Adobe Authentic Documents, Microsoft document signing), or a critical extension PdfCraft doesn't process makes the verdict Unknown instead of Valid, and the details say which. Certificate policies are accepted as any policy. In-process certificates test each refusal and the accepted cases; a signed PDF from a trusted TLS-server certificate was Valid before and is Unknown now. Still missing: name constraints, and unknown critical extensions on issuer certificates. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M14, side panel width):** The side panel keeps the width it is dragged to. The Bookmarks search row was 2 px wider than the panel, and a resizable egui panel keeps the width its content used, so every repaint (any pointer move) widened it up to 520 px. The Comments reply row did the same in languages with a longer "Post" (Russian, Spanish, …); Post now sits under a full-width reply field. Tests hold both panels' widths steady across 30 repaints. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M8):** Redaction codes. Redaction properties offers "Custom text" or "Redaction code" under "Use overlay text": pick the U.S. FOIA (5 U.S.C. 552(b)) or U.S. Privacy Act (552a) set and tick codes, and new marks carry them as overlay text in the set's order ("(b)(6), (b)(7)(C)"). `redact_mark` takes `code_set` and `codes`, rejecting unknown sets and codes with the allowed list. Tests cover the code sets, the tool end to end (mark, apply, text) and the dialog; the new label is translated in all 13 UI catalogs. `protect.redaction-codes` is partial: Edit Codes, custom sets and their import/export are still missing. Estimates unchanged.
- **2026-10-10 (M14, platform shortcut labels):** View ▸ Zoom and Page navigation, the rail, find-bar, toolbar and bookmark tooltips, and Help ▸ Keyboard shortcuts write shortcuts for the platform (`Ctrl+1`, `Ctrl+Shift++` off macOS) instead of hard-coded ⌘. Labels follow egui's detected OS, so the web build in a Linux or Windows browser shows Ctrl too. Fit visible is listed once in View. Translated tooltips take a `{key}` placeholder. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M14):** Arabic interface language: a 2,137-entry catalog (`ar.tsv`, six CLDR plural forms, Arabic month and weekday names for dates), 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, edge spaces and caption colons are kept in place), and the command search matches a multi-word Arabic query word by word. Arabic glyphs come from the craft-fonts `Arab` face (kept in the web build); the CI and release pin of craft-fonts moves to the revision that has it, and `crates/fonts/build.rs` now refuses to embed the Noto Sans CJK SC that revision also carries (AGENTS.md §1.1). Not done: the layout is not mirrored, a wrapped Arabic paragraph breaks at the wrong end, and text typed into fields is not reordered; these need bidi support in the text layout itself.
- **2026-10-10 (M6, #95):** Prepare a form edits shared General, Appearance and applicable Options properties in one atomic undo step. Explicit opt-in controls keep mixed values unchanged; partial appearance patches preserve custom font resources, each widget's style, indirect dictionary contents and unknown keys. Ctrl/Command+A selects the current page; rectangle selection and the Fields panel support multi-selection, with panel double-click opening properties. CLI/MCP `form_set_props` accepts a bounded, unique `fields` list. Regression tests cover save/reopen, undo/redo, locked-field rollback, stale dialogs, invalid arguments and selection. Names, positions, choices/defaults and script tabs remain individual GUI edits. Integrated upstream 0.5.0 and completed Portuguese/Italian catalogs without losing translator credit; shared-property labels now cover all 14 bundled translations. Kept the new Italian interface and Fill & Sign date-language registries aligned, including accented date output; the About-dialog regression waits for the larger contributor list to settle before clicking. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (M5, line-ending labels):** Comment properties names each line ending in words ("Reverse open arrow", "Butt", …) and those labels are translated. The PDF `/LE` token is unchanged. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M3, #171):** A browser startup `?file=` download opens in a background tab once the user has started working, so it cannot switch the active document or the next Save. Deliberate opens still activate their tab; Home, Combine files, workspace choices and another file's password prompt are preserved. Initial-view geometry is applied to the background document, and its script notices wait until its tab is selected. Synthetic shell tests cover the race, saving the local edit, tab switching, encrypted arrivals, errors and converted text. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (never-crash, #378):** On Windows the window is drawn on the GPU that drives the primary display, as on Linux: `EnumDisplayDevices` (through the safe `winsafe` crate) says which adapter shows each desktop display, and `pick_adapter` matches it to the wgpu adapters by PCI ids. Before, the low-power preference chose a Ryzen integrated GPU with no monitor on a desktop whose monitors hang off an NVIDIA card, and presenting across adapters took the AMD display driver down for two minutes. The log now names the GPU chosen. Synthetic tests for the Windows device-id parser and the primary-display rule; a live test on the running machine. Overall unchanged.
- **2026-10-09 (page preview resolution):** The page view draws whole pages at twice the screen density when that bitmap still fits, then filters it down, so type and fine lines keep more detail. Print preview, page thumbnails, alternate-text figures and the page behind a moving signature are rendered at the size they are shown instead of being stretched up from a small image. Milestone and effort estimates unchanged.
- **2026-10-09 (M5, line endings):** Lines, connected lines and callouts draw all ten standard line-ending styles. `comment_add` accepts `endings`, and Comment properties can change them in one undo step. An unknown name is left undrawn, and a non-finite direction is rejected. Cloudy borders remain polygon-only, so `comment.line-endings` stays partial. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M6, field rotation):** Regenerated form appearances honour `/MK /R` (0, 90, 180, 270). Filling or restyling a rotated field keeps the text, check mark and button caption turned, and Field Properties ▸ Position and `form_set_props` `rotation` set it, swapping width and height around the field's center when the axis changes. `form.appearance-regeneration` stays partial (Helvetica metrics are still approximate). Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M2, JPX sample normalization):** JPEG 2000 rescaling uses bounded integer arithmetic for supported 1–32-bit samples, including the 32-bit boundary that previously shifted past the integer width. Zero-sized rows are refused before scaling. Small contributor-original codestreams exercise decoding and rendering; endpoint and standard-depth regressions cover the same helper. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M14, #577):** The desktop GPU setup enables the selected adapter's supported 2D texture extent instead of egui-wgpu's fixed 8192-pixel extent. This removes the artificial surface ceiling behind a mixed-DPI Windows startup crash when opening Outlook PDF attachments. Synthetic regressions cover the observed 8600 × 3423 surface, downlevel adapters and preservation of other device limits. Hardware resolution limits still apply; milestone and overall effort estimates are unchanged.
- **2026-10-09 (M14, #577 follow-up):** A window larger than the GPU's texture limit no longer closes PdfCraft. Vendored egui-wgpu 0.36.2 fits the window's surface within `max_texture_dimension_2d` and draws egui at the matching scale, on every path that sizes a surface (start-up restore, resize, maximise, DPI change, a recreated surface); upstream is emilk/egui#8361. Root cause of the Outlook-only trigger: winit creates the window on the launching app's monitor and converts the restored size at that monitor's scale. Unit tests cover the 8600 × 3423 case and edge sizes; a Windows Sandbox (WARP) run of a window one pixel over the limit panicked before and kept drawing after. Estimates unchanged.
- **2026-10-09 (M14, #577 follow-up):** A window larger than the GPU's texture limit no longer closes PdfCraft. Vendored egui-wgpu 0.36.2 fits the window's surface within `max_texture_dimension_2d` and draws egui at the matching scale, on every path that sizes a surface (start-up restore, resize, maximise, DPI change, a recreated surface); upstream is emilk/egui#8361. Root cause of the Outlook-only trigger: winit creates the window on the launching app's monitor and converts the restored size at that monitor's scale. Unit tests cover the 8600 × 3423 case and edge sizes; a Windows Sandbox (WARP) run of a window one pixel over the limit panicked before and kept drawing after. Estimates unchanged.
- **2026-10-09 (M14, #519):** A GPU that can't configure the window's surface at launch no longer panics, so PdfCraft's OpenGL retry runs. Vendored egui-wgpu configures a new window's surface inside wgpu error scopes (`winit::catch_errors`) and returns a failure as an error, so eframe returns `Error::Wgpu` and the existing OpenGL retry (#530) runs (a device lost during that configure still panics, on the first frame). Before, wgpu's default handler panicked: on a 2015 Intel GPU with no D3D12 driver wgpu picked the GL adapter over WARP and timed out in `Surface::configure` (`GpuWaitTimeout`). A real-device regression test (a wgpu validation error inside `catch_errors`) panicked with the scopes removed; in Windows Sandbox a forced configure failure panicked unpatched and retried with OpenGL patched (Sandbox has no OpenGL 2.0, so the OpenGL window itself was not shown there). Defaulting Windows to DX12 only (WARP instead of wgpu's GL backend) is left to the maintainers. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M5, Fill & Sign flatten on save):** Preferences and `doc_save` can bake Fill & Sign text, marks and signatures into the page when saving. The option defaults off. Other comments and form fields stay, a second flatten does not reuse an XObject name already on the page, and a document that cannot be changed is left unwritten. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M3.9, native control lifecycle):** The opt-in UI control channel can request bounded window sizes, report observed dimensions, focus accessible widgets and inspect actual keyboard focus. Graceful quit uses the existing unsaved-work prompt, including uncommitted typing; force/discard parameters are rejected. Tests cover resize validation, Tab/Shift-Tab traversal, keyboard activation, invalid focus targets and quit cancellation with retained edits. Focused buttons preserve document undo/redo shortcuts, while text editors keep their own undo history. No milestone percentage change.
- **2026-10-09 (UI.5, shared tab interaction):** Document and Combine tabs use revision-pinned `craft-ui` interaction, with accessible tab roles, selected state, keyboard activation and a theme-colored focus outline. Document IDs keep tab identity stable through dirty-label changes and earlier-tab removal. Existing labels, painting, horizontal scrolling and close requests stay in the frontend. No milestone percentage change.
- **2026-10-09 (UI.2, shared icon buttons):** The frontend uses revision-pinned `craft-ui` for its SVG catalog and square icon buttons. Existing assets, fallback cache keys, dark/light colors, tool labels and commands remain app-owned. Buttons gain a visible keyboard-focus outline; the layer checker keeps the shared dependency at L7. No milestone percentage change.
- **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged.
- **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (community, M7, Hebrew in Edit text):** Editing existing Hebrew text showed it reversed (`בדיוק` read back letter-reversed) and wrote replacement boxes on save. Content streams draw right-to-left lines in visual order; Edit text now reads lines and paragraphs in typing order and writes them back in drawing order (`pdfcraft-edit::bidi`, UAX #9 through `unicode-bidi`, with numbers, Latin and mirrored brackets; where visual order allows several readings, the one that draws back exactly and pairs its brackets wins). Right-to-left paragraphs are right-aligned unless an alignment is chosen, and a single line keeps its right edge as it grows. Letters the page's font lacks are drawn with a craft-fonts `Hebr` face (Noto Sans Hebrew, storytold/craft-fonts PR) as Type 3 glyphs with ToUnicode, so the result is searchable; the interface uses that face too, and the web build keeps it. Without it, a clear error and no change. `edit.rtl-cjk-editing` moves to partial. Not yet: niqqud in replacement text (refused, not misdrawn), Arabic in Edit text (shaping; Add text has it since #403), typing right to left in the box (#437), lines drawn word by word from the right; the release craft-fonts pin must move to a revision with the Hebrew face. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M2, bounded page labels):** Inspector number trees have shared work, depth, and cycle limits; prefixes are bounded before decoding and complete custom labels have per-label and aggregate UTF-8 byte caps. A refused tree leaves physical page numbers intact and records one warning, with custom labels applied only after every range succeeds. Pure COS Roman and repeated-letter formatters are shared with Organize; label queries and edits return errors for unsupported output before changing the stored tree, while ordinary labels and prefix-only ranges remain supported. Synthetic tests cover safe just-over-limit cases, large first numbers, aliases, cycles, wide/deep trees, UTF-16/UTF-8 boundaries, aggregate output, and unchanged refused edits. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (M4, page grid zoom):** The Organize page grid zooms from 50% to 300%: toolbar buttons with a percentage that resets, trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0. Pages drawn larger than their thumbnails are rendered at the size drawn while in view, so text stays sharp in documents of any length. Not yet: a slider, remembering the size, a size control for the Pages panel. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M14, #307, upstream integration):** Rebased the local candidate onto main `a6bc63a`, preserving render-request deduplication, pixel-aligned rasters, signature editing and organizer insertion controls. Regressions cover inactive signature-preview cleanup, insertion gaps under virtualization, and canceled/reintroduced render requests. All 1,336 workspace tests pass (6 ignored), 193 parser tests pass (2 missing-fixture tests filtered), strict workspace/all-target Clippy passes without allowances, and 30 WASM crate checks pass. All 1,010 navigation frames, 12 workflow artifacts and eight 100-page external PDF checks match; 1×/2× desktop protocols pass again. Inspection is 497.7 MiB / 1.78 s, full-save 1.06 GiB / 29.64 s, and fresh desktop navigation/close/reopen 877 MiB. The desktop result is higher than the pre-rebase 832 MiB, while remaining 21.9% below the earlier bounded-writer stage. Windows, cold-start latency and global memory budgets remain open; no milestone percentage increase.
- **2026-10-09 (M7, grouped artwork):** Edit text/images selects page-drawn Form XObjects as whole figures, including nested raster/vector/text content. Their BBox and optional Matrix determine the selection; move/resize/rotate/flip/delete rewrite only the selected page placement, preserving shared Forms and other placements. Engine, automation and UI core regressions cover geometry, rendered movement, save/reopen, undo and selection/drag/Delete. Bitmap replacement/extraction remain raster-only; inline images and individual elements inside a Form are not editable through this tool. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged.
- **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged.
- **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M5.7, signature placement):** Image signatures and initials preview at the pointer at their actual page size, including zoom and document/view rotation. A successful placement switches to Select with the new annotation selected; stamp resize handles use the existing engine edit, automation tool and undo history while preserving the appearance. Synthetic rendered UI and automation tests verify positioning, one placement per selection, resizing, undo/redo and save/reopen. Milestone and overall effort estimates unchanged.
- **2026-10-08 (M5.7, image signatures):** Fill & Sign imports local PNG/JPEG signatures and initials through the Image tab, with previews, local persistence and undoable placement. The shared engine validates bounded images and preserves transparency and orientation; `fill_sign_add` accepts an image path. Synthetic UI and automation tests cover cancellation, invalid input, settings, undo/redo and save/reopen rendering. Overall effort estimate unchanged (about 30–35%).
- **2026-10-08 (M14, #307, confirmed baseline OOM):** Removed the benchmark RSS guard and ran the frozen original and optimized CLI binaries in separate containers with a verified 15 GiB kernel memory limit, no swap and four CPUs. The unmodified baseline was kernel OOM-killed after 20.06 s with 14.97 GiB observed RSS; Docker recorded `OOMKilled=true`, exit 137 and an `oom` event. The optimized build completed the same 9,156-page inspection at 497.5 MiB in 1.75 s with unchanged metadata and no warnings. The original RSS remains a lower bound and elapsed time is time to failure. The kernel limit still constrains this experiment; no remote run was needed. No product changes or milestone percentage increase.
- **2026-10-08 (M14, #307, original-baseline cutoff):** Repeated original unmodified CLI inspection with a 14 GiB RSS guard, up from 10 GiB, on the unchanged 9,156-page PDF. It reached 14,682,080 KiB and was stopped after 48.21 s before completion; no cgroup OOM kill occurred. The optimized build completed the same inspection at 495.7 MiB in 1.70 s. The original measurement is a lower bound, not a completed peak or timing. This guarded run failed before completion without an OS OOM kill. The user closed the larger-host follow-up; the prepared benchmark is retained as evidence. The later local guard-free experiment is recorded above. No product changes or milestone percentage increase.
- **2026-10-08 (M14, #307, shared parsing and raster budgets):** Native render workers share a lazily initialized parser after cold-load/repair concurrency and watchdog recovery fixes, while retaining private render caches. Pages, organizer and Print request current visible thumbnails; retained active-view textures are limited to 24/128/128 MiB for thumbnails/pages/tiles and completed raster storage to 32 MiB/32 results, allowing one oversized result alone. Signature discovery uses a bounded source/edit-aware cache; the writer borrows unchanged objects and reuses xref rows. Engine navigation drops from 997 to 807 MiB with all 1,010 frame hashes unchanged. Three-trial full/incremental-save workflow times improve to 29.92/16.35 s at unchanged 1.06/1.08 GiB peaks; full serialization remains slower than before bounded saving, and the single mixed workflow takes 67.99 s versus 65.52 s. All 1,158 workspace tests pass (6 ignored in the regular run), 193 vendor tests pass (2 missing-fixture tests filtered), eight outputs pass 100-page external checks, and 29 WASM crate checks pass. Existing strict workspace Clippy warnings remain; UI lint validation explicitly allows the existing collapsible-match category. Fresh desktop navigation/close/reopen falls from 1,123 to 832 MiB; 1x/2x GUI runs pass visible-demand and texture-budget checks on original 10,000-page fixtures and the real file. Output-capacity and LRU experiments did not justify production changes. These are local Linux results and specific storage budgets, not global RAM limits or Windows validation. No parity or milestone percentage increase.
- **2026-10-08 (M14, #307, bounded saving):** Full rewriting now uses private temporary caches and packs objects in batches limited by count and serialized bytes, preserving unknown objects, reference identity and encryption behavior without populating undo snapshots. Native render workers initialize only on a live request, avoiding unused parser copies during refresh/rebase. Three same-file Linux trials reduce rotate/full-save/close/reopen peak RSS from 3.69 to 1.06 GiB (35.53 to 32.51 s); incremental workflow RSS falls from 1.56 to 1.08 GiB. All 1,114 workspace tests pass (4 ignored); eight saved outputs pass external text/raster checks on 100 sampled pages each, and 1,010 navigation frames match. First use of each worker now pays parser initialization; steady-state viewing remains about 1 GiB. Single oversized objects, whole source/output buffers, and Windows behavior remain outside a global budget claim. No parity or milestone percentage increase.
- **2026-10-08 (M14, #307, research/comparison):** Added a local external-reference suite outside the repository, using the same 9,156-page PDF with MuPDF, PDFium, Poppler and qpdf. Three Linux trials put the candidate's single-renderer 102-frame peak at 392 MiB, versus 348/347 MiB for MuPDF/PDFium with whole-file bytes (164/163 MiB with file input). The rotate/full-save/close/reopen workflow peaks at 3.69 GiB during serialization; incremental saving peaks at 1.56 GiB during rebasing. Different UI features, input/cache policies and writer compression settings prevent treating these as app rankings. Next targets are bounded object-stream packing, parsed-object lifetimes, snapshot overlap and file-backed input. No further runtime optimization or milestone percentage increase in this research step.
- **2026-10-08 (M14, #307, local candidate):** Validated the 192.9 MB, 9,156-page VMware document in Linux. The original CLI exceeded a 10 GiB measurement cutoff before finishing inspection; the candidate inspects in about 1.8 s at 496 MiB and a 1,000-page navigation/close/reopen probe peaks at 990 MiB. It shares validated large resource indexes within each parser, reads panel structure on demand through COS with the previous inspector as a compatibility fallback, and scans standalone timestamps with temporary caches instead of retaining every object in the editor. Inspection output and 104 common sampled page renders match the handoff candidate. This is a different PDF revision from the original Windows issue; Windows GUI private memory and responsiveness remain unverified. No parity or milestone percentage increase.
- **2026-10-08 (M9, #179):** Windows Current User Personal certificate store identities join file-based digital IDs, signing through CNG without exporting keys. RSA-2048 and ECDSA P-256 round trips use temporary certificates with cleanup guards; enumeration, missing identities, automation errors and the password-free UI have regression coverage. Smart cards and PKCS #11 remain planned; overall effort estimate unchanged.
Newest first. One line per session: the date, what moved, and the new overall percentage.
- **2026-10-09 (M9, review of #144):** Chain building checks what an issuer may do. `x509::build_chain` linked certificates by name and signature only, so an ordinary subscriber certificate embedded in a PDF, or one in the trusted list, could "issue" a certificate for any identity and chain to a trusted root. An issuer now needs basicConstraints CA:TRUE (an old v1 self-signed root without constraints still anchors), keyCertSign when it has a key usage, a pathLenConstraint that allows the CAs below it, and validity at the signing time (or the trusted timestamp/token time) when known; the signature's details say why an issuer was refused. Negative tests issue certificates in-process and cover each refusal, plus a signed PDF whose signer chains through an end-entity certificate (Unknown, not Valid). Still missing: key usage and extended key usage of the signer, name constraints and unknown critical extensions. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M9):** A document timestamp stored as a signature field (Documenso writes `Timestamp_1` that way, its `/V` a `/Type /DocTimeStamp` dictionary) was read as a CMS signature and reported "altered or corrupted since the signature was applied" on an untouched file. A field value with `/SubFilter /ETSI.RFC3161` is validated as a timestamp now, and standalone timestamps are found by `/DocTimeStamp` (ISO 32000-2 §12.8.5) as well as the `/Type /Sig` older writers used; `timestamp_document` writes `/DocTimeStamp`. Regression tests build the same structure with a test TSA. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M9, signatures as DocuSign and Documenso write them, #144):** A real DocuSign + Telekom qualified-signature PDF showed "DER signature wrong" although Acrobat validates it: both CMS blobs use BER indefinite lengths. The CMS is read with the BER reader that #453 added (`Tlv::parse_ber`: indefinite lengths, segmented OCTET STRINGs, depth-bounded) while certificates and signed attributes are still verified over their own exact bytes, and the details say the signature was BER; tests build the nesting bombs a crafted /Contents could hold. Behind that, later revisions that add DSS data (indirect `/Certs`, `/VRI`) or rewrite the catalog's XMP were classified as disallowed changes; they are permitted now, narrowly (review by echelon on #144): only objects reached through `/DSS` `/Certs|CRLs|OCSPs|VRI` with the shape of their role, and new or only grown since signing, count as the store, and only the catalog's `/Metadata` is the document's XMP. A `/DSS` entry naming a page's contents, or a `/Type /DSS` or `/Type /Metadata` label on an existing dictionary or stream, makes nothing permitted. Six of the new tests fail on the previous code, among them a Form XObject labelled `/Type /Metadata` before signing that could be rewritten under a Valid signature. Negative tests at every DocMDP level. Cross-checked with pdfsig (both signatures valid). Still missing: AATL/EUTL trust lists (a separate change: they need CA-constraint checks in chain building first). Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M9, signature algorithms, #386):** Validation reads what real signers write. Digests SHA-224, SHA-512/224, SHA-512/256, SHA3-224/256/384/512 and RIPEMD-160 (beside SHA-1/2); RSA PKCS#1 with or without the DigestInfo's NULL (checked by building the one expected encoding and comparing it byte for byte, in `rsa_pad`, for every digest; a bare digest without any DigestInfo is refused, it doesn't say which hash it uses); RSA-PSS with the hash, MGF1 hash and salt length the signature declares; ECDSA as DER, non-minimal DER or raw r‖s on P-256, P-384, P-521 and brainpoolP256r1/P384r1/P512r1 (the last hand-written on `crypto-bigint`, no RustCrypto crate exists); Ed25519 (RFC 8419); the legacy `adbe.x509.rsa_sha1` format. Algorithms PdfCraft can't check give Unknown instead of Invalid. Review of #386 (echelon): an RSA signature value must be below the modulus (RFC 8017 §5.2.2; `s` and `s + n` both verified before), and SHA-1 and RIPEMD-160 signatures still validate but the signature's details now say the algorithm is weak (wording pinned by tests). Found on the way, each with a regression test that failed first: a brainpoolP512r1 key with a coordinate not reduced modulo p panicked inside the bigint arithmetic (the certificate comes from the PDF), now refused, and the point at infinity is an invalid signature rather than an error. Tolerated encodings are listed in the signature's details. Vectors made with OpenSSL; cross-checked with pdfsig. Still missing: Ed448/DSA, signing with the new digests. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M14, German interface follow-up):** Follow the merged German catalog with shorter search/action labels, corrected callout/accessibility wording and a distinct certificate Subject context. Localized tool names retain their capitalization and comment composer titles go through the catalog. Search hints reserve shortcut space and Home cards bound text with full hover text; layout, context and German Preferences/search control regression tests cover these changes. Overall effort estimate remains about 30–35%.
- **2026-10-09 (M2, PDF-function hardening):** Vendored function construction has a shared 64-level/10,000-node limit and path cycle detection; calculator parsing and evaluation have shared depth/work budgets. Calculator integer division, logical shifts, rotations and indices avoid input-driven panics, and fixed-size stack errors propagate. Synthetic tests exercise exact/over limits, shared children and cyclic references, with invalid shading calculations preserving later page content. Limits return the existing optional failure and log their reason when logging is enabled. Milestone/overall effort estimates remain unchanged (about 30–35%).
- **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M2, image resampling bounds):** Image resampling checks source buffers, finite transforms, target RGBA sizes, intermediate scratch dimensions and estimated filter work before planning or allocating; wide valid sources may shrink before backend side limits. Oversized or failed resampling retains the original pixels and placement when the source fits the backend; invalid sources, unrepresentable fallbacks and failed staging/output reserves are skipped with a logged warning. Small synthetic metadata regressions and an anisotropically transformed image cover the guards and fallback. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M14, Hungarian):** Magyar (`hu`) is a complete interface catalog (2,036 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `hu` and `hu_HU`, and from Preferences. Informal "te"; commands are nouns (Mentés, Megnyitás). After a numeral the noun stays singular, so both plural forms match. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover Hungarian diacritics including ő and ű. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M14, Windows Auto):** Neutral launcher locales (`C`, `C.UTF-8`, `POSIX`) no longer select English ahead of the Windows preferred display-language list. Explicit supported locale overrides retain their priority. Regression tests use existing catalogs and cover neutral/mixed overrides, unsupported first display tags and multiple preferred Windows languages. Catalogs and other platforms are unchanged; overall effort estimate remains about 30–35%.
- **2026-10-09 (community, M14, German):** Deutsch (`de`) is a complete interface catalog (2,035 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `de`, `de_DE`, `de_AT`, `de_CH` and other German locales, and from Preferences. Informal "du", infinitive for commands. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover umlauts, ß and German quotes. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M7, Arabic added text):** Add text writes Arabic into the PDF instead of `?`. The text is shaped with the craft-fonts `Arab` face through harfrust (already in the tree via egui): joining forms, ligatures and mark positions. Each line is laid out in display order by the Unicode bidirectional algorithm, so Arabic mixed with Latin words, European or Arabic-Indic digits and brackets reads correctly; justified right-to-left paragraphs end at the right edge. Each cluster (a letter with its dots or marks) becomes one glyph of a Type 3 font built from the face's outlines, with a ToUnicode entry, so `text_extract`, copy and search get every letter once and no font file is embedded. Wrapping measures shaped words. Each item has its own fonts under names free on the page (a full save renumbers objects but not resource names); updating or deleting the item removes them. Without the face, adding Arabic text returns a clear error and changes nothing, while an existing item holding Arabic still moves and retypes, drawn as `?` as before. Tests cover shaping, display order, ToUnicode, font names and clean-up on one page, and odd input (empty lines, direction controls, lone marks, 18,000 characters). Not yet: bold/italic Arabic, Hebrew, right-to-left typing in the box (misc.rtl-ui), editing existing Arabic text (edit.rtl-cjk-editing). The craft-fonts revision pinned for releases predates its Arabic face, so release builds report the missing font until the pin moves. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M2, predictor hardening):** Empty PNG predictor frames return without row buffers. Packed TIFF Predictor 2 rows reject overflowing dimensions and size per-component scratch to the samples present in the input, aligned with upstream lopdf#589. Synthetic regressions cover malformed dimensions, partial rows, 1/2/4-bit decoding and padding. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, document inspection):** Annotation dates preserve non-date Unicode text instead of panicking while opening; failed auxiliary structure inspection keeps renderer page geometry and reports a warning. Synthetic UTF16BE annotation, UTF8-boundary and independent panic regressions cover the fix. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M14, #503):** Ukrainian (Українська, `uk`) adds 2,047 original catalog entries covering commands, tools, dialogs, panels, notices, history and contributor controls. Auto resolves `uk`/`uk_UA`; the manual preference persists. Integer plurals distinguish one/few/many, including 11–14 and large counts; fixed-count templates use count-neutral wording. Calendar dates use genitive month names; Home cards fit wrapped text and the search hint keeps clear of its shortcut. Coverage checks scan UI literals, and font checks use bundled Inter and JetBrains Mono, including ґ/є/і/ї. Control tests check translated Preferences, command search, error framing and language switching with unsaved documents. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M5, #158):** Fill & Sign date format. Preferences ▸ Date format sets the pattern Fill & Sign dates use: 12 presets or any pattern in Acrobat date letters (with `\` escapes), checked and previewed as typed, remembered, and settable through `fill_sign_date_format`, `fill_sign_add` `format`/`language` and `ui set date-format`/`date-language`. Month and weekday names come in the interface languages (Czech in the genitive after a day), following the interface language unless one is picked. Fill & Sign text is still Helvetica/WinAnsi, so a date with other letters (Czech ř, Japanese, Chinese) is refused with a warning rather than saved as "?". This goes past Acrobat, whose Fill & Sign has no format setting. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M14, #503 follow-up):** Reviewed the complete Ukrainian catalog with VESUM 6.8.6 and manually checked the labels added when #509 landed. Certificate Subject now names the certificate holder separately from the document topic; certificate types and fingerprints, equal field sizes, keyboard/assistive-technology guidance and table sections use clearer contextual wording. Reviewed technical compounds and abbreviations remain unchanged. Overall effort estimate remains about 30–35%.
- **2026-10-09 (community, Home pinned folders):** Home gets a Folders section: File ▸ Pin folder to Home… (or "Pin a folder…" on Home) pins up to 8 folders, and each lists its newest PDFs (name, how long ago, size; 8 at first, up to 100 with Show more), one click to open, Unpin to remove. Folders are read on a worker thread and listed again every few seconds while Home shows, so new scans appear without reopening anything; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder is described by where it is ("iCloud Drive", "OneDrive · Contoso", "Documents › Work") rather than its full path, so two folders called "Scans" can be told apart. Cloud drives work through their local synced folder; nothing is read through a provider's API. Settings keep only absolute paths, without duplicates. Command label translated in every catalog; Home strings in French, Spanish, Japanese and both Chinese catalogs. Not yet: subfolders, search inside a pinned folder, the web build. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M14, Telugu):** Telugu (తెలుగు, `te`) as a complete catalog of 1,984 entries covering every command, All tools entry, dialog, panel and notice; `te` and `te_IN` locales follow it. Telugu text is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, storytold/craft-fonts#10), kept in the web build too, after the CJK and Arabic faces; egui shapes its conjuncts. Commands use the plain imperative and sentences the polite form. The craft-fonts revision pinned for releases has no Telugu face yet, so release builds show Telugu as replacement boxes until the pin moves. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M14, Simplified Chinese About):** The About tabs, contributor controls and statistics, and model table now have Simplified Chinese translations, along with multiline link-privacy and cut-and-stack guidance. Coverage checks include direct `i18n::t` calls, multiline literals and dynamic credit labels; UI control checks exercise the translated About tabs and tables. Chinese font delivery remains incomplete; overall ≈ 30–35% (unchanged).
- **2026-10-09 (M14, #305):** Windows MSI rejects per-user installation overrides while allowing removal of legacy per-user installs and keeps its progress message visible. README documents unattended deployment without a desktop shortcut. Compiled x64 MSI checks pass; ARM64 install regressions added for CI. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M13, MCP #451):** common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters.
- **2026-10-08 (M14, #307, upstream integration):** Rebased the local candidate onto main `a6bc63a`, preserving render-request deduplication, pixel-aligned rasters, signature editing and organizer insertion controls. Regressions cover inactive signature-preview cleanup, insertion gaps under virtualization, and canceled/reintroduced render requests. All 1,336 workspace tests pass (6 ignored), 193 parser tests pass (2 missing-fixture tests filtered), strict workspace/all-target Clippy passes without allowances, and 30 WASM crate checks pass. All 1,010 navigation frames, 12 workflow artifacts and eight 100-page external PDF checks match; 1×/2× desktop protocols pass again. Inspection is 497.7 MiB / 1.78 s, full-save 1.06 GiB / 29.64 s, and fresh desktop navigation/close/reopen 877 MiB. The desktop result is higher than the pre-rebase 832 MiB, while remaining 21.9% below the earlier bounded-writer stage. Windows, cold-start latency and global memory budgets remain open; no milestone percentage increase.
- **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged.
- **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged.
- **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M5.7, signature placement):** Image signatures and initials preview at the pointer at their actual page size, including zoom and document/view rotation. A successful placement switches to Select with the new annotation selected; stamp resize handles use the existing engine edit, automation tool and undo history while preserving the appearance. Synthetic rendered UI and automation tests verify positioning, one placement per selection, resizing, undo/redo and save/reopen. Milestone and overall effort estimates unchanged.
- **2026-10-08 (M5.7, image signatures):** Fill & Sign imports local PNG/JPEG signatures and initials through the Image tab, with previews, local persistence and undoable placement. The shared engine validates bounded images and preserves transparency and orientation; `fill_sign_add` accepts an image path. Synthetic UI and automation tests cover cancellation, invalid input, settings, undo/redo and save/reopen rendering. Overall effort estimate unchanged (about 30–35%).
- **2026-10-08 (M14, #307, confirmed baseline OOM):** Removed the benchmark RSS guard and ran the frozen original and optimized CLI binaries in separate containers with a verified 15 GiB kernel memory limit, no swap and four CPUs. The unmodified baseline was kernel OOM-killed after 20.06 s with 14.97 GiB observed RSS; Docker recorded `OOMKilled=true`, exit 137 and an `oom` event. The optimized build completed the same 9,156-page inspection at 497.5 MiB in 1.75 s with unchanged metadata and no warnings. The original RSS remains a lower bound and elapsed time is time to failure. The kernel limit still constrains this experiment; no remote run was needed. No product changes or milestone percentage increase.
- **2026-10-08 (M14, #307, original-baseline cutoff):** Repeated original unmodified CLI inspection with a 14 GiB RSS guard, up from 10 GiB, on the unchanged 9,156-page PDF. It reached 14,682,080 KiB and was stopped after 48.21 s before completion; no cgroup OOM kill occurred. The optimized build completed the same inspection at 495.7 MiB in 1.70 s. The original measurement is a lower bound, not a completed peak or timing. This guarded run failed before completion without an OS OOM kill. The user closed the larger-host follow-up; the prepared benchmark is retained as evidence. The later local guard-free experiment is recorded above. No product changes or milestone percentage increase.
- **2026-10-08 (M14, #307, shared parsing and raster budgets):** Native render workers share a lazily initialized parser after cold-load/repair concurrency and watchdog recovery fixes, while retaining private render caches. Pages, organizer and Print request current visible thumbnails; retained active-view textures are limited to 24/128/128 MiB for thumbnails/pages/tiles and completed raster storage to 32 MiB/32 results, allowing one oversized result alone. Signature discovery uses a bounded source/edit-aware cache; the writer borrows unchanged objects and reuses xref rows. Engine navigation drops from 997 to 807 MiB with all 1,010 frame hashes unchanged. Three-trial full/incremental-save workflow times improve to 29.92/16.35 s at unchanged 1.06/1.08 GiB peaks; full serialization remains slower than before bounded saving, and the single mixed workflow takes 67.99 s versus 65.52 s. All 1,158 workspace tests pass (6 ignored in the regular run), 193 vendor tests pass (2 missing-fixture tests filtered), eight outputs pass 100-page external checks, and 29 WASM crate checks pass. Existing strict workspace Clippy warnings remain; UI lint validation explicitly allows the existing collapsible-match category. Fresh desktop navigation/close/reopen falls from 1,123 to 832 MiB; 1x/2x GUI runs pass visible-demand and texture-budget checks on original 10,000-page fixtures and the real file. Output-capacity and LRU experiments did not justify production changes. These are local Linux results and specific storage budgets, not global RAM limits or Windows validation. No parity or milestone percentage increase.
- **2026-10-08 (M14, #307, bounded saving):** Full rewriting now uses private temporary caches and packs objects in batches limited by count and serialized bytes, preserving unknown objects, reference identity and encryption behavior without populating undo snapshots. Native render workers initialize only on a live request, avoiding unused parser copies during refresh/rebase. Three same-file Linux trials reduce rotate/full-save/close/reopen peak RSS from 3.69 to 1.06 GiB (35.53 to 32.51 s); incremental workflow RSS falls from 1.56 to 1.08 GiB. All 1,114 workspace tests pass (4 ignored); eight saved outputs pass external text/raster checks on 100 sampled pages each, and 1,010 navigation frames match. First use of each worker now pays parser initialization; steady-state viewing remains about 1 GiB. Single oversized objects, whole source/output buffers, and Windows behavior remain outside a global budget claim. No parity or milestone percentage increase.
- **2026-10-08 (M14, #307, research/comparison):** Added a local external-reference suite outside the repository, using the same 9,156-page PDF with MuPDF, PDFium, Poppler and qpdf. Three Linux trials put the candidate's single-renderer 102-frame peak at 392 MiB, versus 348/347 MiB for MuPDF/PDFium with whole-file bytes (164/163 MiB with file input). The rotate/full-save/close/reopen workflow peaks at 3.69 GiB during serialization; incremental saving peaks at 1.56 GiB during rebasing. Different UI features, input/cache policies and writer compression settings prevent treating these as app rankings. Next targets are bounded object-stream packing, parsed-object lifetimes, snapshot overlap and file-backed input. No further runtime optimization or milestone percentage increase in this research step.
- **2026-10-08 (M14, #307, local candidate):** Validated the 192.9 MB, 9,156-page VMware document in Linux. The original CLI exceeded a 10 GiB measurement cutoff before finishing inspection; the candidate inspects in about 1.8 s at 496 MiB and a 1,000-page navigation/close/reopen probe peaks at 990 MiB. It shares validated large resource indexes within each parser, reads panel structure on demand through COS with the previous inspector as a compatibility fallback, and scans standalone timestamps with temporary caches instead of retaining every object in the editor. Inspection output and 104 common sampled page renders match the handoff candidate. This is a different PDF revision from the original Windows issue; Windows GUI private memory and responsiveness remain unverified. No parity or milestone percentage increase.
- **2026-10-08 (M9, #179):** Windows Current User Personal certificate store identities join file-based digital IDs, signing through CNG without exporting keys. RSA-2048 and ECDSA P-256 round trips use temporary certificates with cleanup guards; enumeration, missing identities, automation errors and the password-free UI have regression coverage. Smart cards and PKCS #11 remain planned; overall effort estimate unchanged.
- **2026-10-08 (M4, Combine files):** Combine files is now its own tab instead of a dialog: a table of the files (name, pages, size, modified, warnings) whose columns sort, resize and move, with the layout kept in the settings. Files, folders (optionally with subfolders) and open documents can be added, or dropped on the tab; rows select like a file manager (Ctrl/Shift), move as a block by toolbar, Alt+arrows or drag, and every change undoes with ⌘Z. Problems show before Combine: password-protected files, security settings that withhold page assembly, bad page ranges (checked as typed), duplicates; protected files unlock with their open or permissions password, also headlessly (`doc_combine` `passwords`). The result is never encrypted, and the list says so. `organize.combine-sort` shipped; `organize.combine-options` partial (open files). Still missing: protecting the result in the same step, file-size and PDF/A options. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M14, French):** Français (`fr`) is a complete interface catalog (about 1,880 entries), selected automatically for `fr`, `fr_FR`, `fr_CA` and other French locales, and from Preferences. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names. Latin UI faces cover the catalog. Localization remains partial for Czech and Brazilian Portuguese; overall ≈ 30–35% (unchanged).
- **2026-10-08 (M14, right-to-left file names):** A tab with an Arabic or Hebrew file name showed replacement boxes. The interface now takes an `Arab` face from craft-fonts when the build input has one, and on desktop falls back to one font installed on the machine for characters no embedded face has (`PDFCRAFT_SYSTEM_FONTS=0` turns that off; README screenshots do). File names and paths in the tab strip, the recent-files list and Properties are put in display order, since egui joins the letters but does not reorder runs. Still missing: multi-line text (bookmarks, comments, search results), text input and a mirrored layout, so `misc.rtl-ui` stays planned; the craft-fonts revision pinned for releases predates its Arabic face, so release builds rely on the installed font until the pin moves. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (community, M3, #214):** In single-page view, when the whole page fits, the wheel turns pages: one per wheel notch, one per trackpad swipe including its momentum. The rail gets a Page display button between rotate and zoom with Acrobat's view choices (Fit to width scrolling, Fit one full page, Actual size, Zoom to page level, Fit visible, Read mode, Full screen) and the page layouts. Preferences gets Default page display and Default zoom, which every new tab uses. In continuous view, Fit page and Fit height fit the largest page, so mixed page sizes no longer jump while scrolling. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M14, Windows Chinese Auto):** Auto now reads the Windows display language after locale environment overrides, selecting Simplified or Traditional Chinese through the existing locale mapping. Added Chinese region/script and Windows query regressions. Chinese font delivery remains incomplete. Localization remains partial; overall ≈ 30–35% (unchanged).
- **2026-10-08 (M3, themes):** Toolbar and Display theme now share System, Light and Dark preferences, one command path and persistent settings; the duplicate View toggle is removed. Effective colours track the OS only in System mode, with egui popup styles kept in sync. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M14, Simplified Chinese):** Added 208 translations, bringing the catalog from 1,625 to 1,833 entries. PhotoCraft-style coverage checks protect registered commands, All tools labels and UI `tl!` literals; generated history and diagnostic regressions preserve user values. Chinese font delivery remains incomplete: the release-pinned craft-fonts input has no Hans face and the web build only embeds the Japanese UI face (see #112 and #140). Localization remains partial; overall ≈ 30–35% (unchanged).
- **2026-10-08 (community, #135):** CLI output exits cleanly when a pipe reader closes early. Other stdout errors return a failure instead of panicking, and command/file errors keep their diagnostics. Synthetic subprocess regressions cover closed pipes and a reader stopping after one line. Parity counts and effort estimate unchanged (≈ 30–35%).
- **2026-10-08 (community):** Cut and stack in Print > Multiple: single-sided n-up sheets can be cut into cell piles and restacked in selected page order, with aligned gutter marks and blanks kept in their cells. Available through doc_print; duplex is refused. Grid-size overflow returns an error. No change to the parity count or the overall estimate (about 30–35%).
- **2026-10-07 (community, #208):** Measure: distance, perimeter and area annotations (standard LineDimension/PolyLineDimension/PolygonDimension with rectilinear /Measure), persistent viewport scales with two-point calibration, vector snapping (endpoints, midpoints, intersections, paths) through nested forms, live readings, CSV export, and eight `measure_*` agent tools. Hardened on landing: snap extraction caps segments and snap targets page-wide and skips undecodable streams and over-deep `q` instead of failing; unsupported imported measurements (compound ft-in, fractional, invalid geometry) are listed as unsupported rather than failing the list; non-ASCII units ("m²") are allowed and rendered in WinAnsi; a closing duplicate polygon vertex counts as closed. Distance and scale are partial (single-unit decimal formats only; Acrobat interop untested). 51.0% shipped; ≈ 30–35% effort.
- **2026-10-07 (community, #190):** Create PDF from Images offers embedded resolution, 72 DPI, or custom DPI (1–1200) without resampling. The engine and doc_create share the choice; Windows image context menus open the chooser through --create-images. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (community, #135):** CLI output exits cleanly when a pipe reader closes early. Other stdout errors return a failure instead of panicking, and command/file errors keep their diagnostics. Synthetic subprocess regressions cover closed pipes and a reader stopping after one line. Parity counts and effort estimate unchanged (≈ 30–35%).
- **2026-10-08 (community):** Cut and stack in Print > Multiple: single-sided n-up sheets can be cut into cell piles and restacked in selected page order, with aligned gutter marks and blanks kept in their cells. Available through doc_print; duplex is refused. Grid-size overflow returns an error. No change to the parity count or the overall estimate (about 30–35%).
- **2026-10-07 (community, #161):** The "Save changes?" prompt keeps a long file name inside the modal: the title wraps to the dialog width, so the centered panel no longer stretches past the viewport, clips its message and pushes Save/Cancel off-screen (Windows report). The password prompt's file-name line wraps the same way, and a regression test pins the title and all three buttons inside a 1365×719 screen. Parity counts unchanged; ≈ 30–35%.
- **2026-10-07 (community, #143):** Windows MSI gets a full-UI welcome dialog with a "Create a desktop shortcut" checkbox (on by default; `INSTALLDESKTOPSHORTCUT=0` for unattended installs), progress, files in use, repair/remove, and a "Setup completed successfully" screen with Finish (plus distinct failed/cancelled screens). Start Menu and desktop shortcuts are now plain links to `pdfcraft.exe` instead of advertised MSI shortcuts. Packaging checks the compiled MSI's shortcut, checkbox and dialog tables; the ARM64 smoke test checks both links' targets, their removal on uninstall, and the opt-out. Installer polish only; feature counts unchanged. ≈ 30–35%.
- **2026-10-07 (localization):** The interface translates through the same system as PhotoCraft: one TSV catalog per language (`crates/ui-egui/src/i18n/ja.tsv`), the `tl!` macro, entries keyed by command id or with plural forms, and catalog tests (format, duplicates, placeholders, command ids). Catalogs are validated strictly (unknown escapes, reserved contexts, placeholders, ellipses, plural forms), a bad line is skipped and logged at run time, `fmt` substitutes in one pass, the drawing language is per thread, and each `.tsv` is an attributed asset (`kind = "translation"`, checked by `cargo xtask assets`); validation, attribution and the language-switch control test folded in from #175. The 49 existing Japanese strings moved over unchanged, plus `Auto`. The `language` preference now defaults to `auto` (follow the system language; English when it has no catalog); `en`/`ja` still persist, and unknown values are rejected. Not covered: dialogs and panels, Windows system-language detection. ≈ 30–35% (unchanged).
- **2026-10-07 (session 16):** PrintCraft is renamed PdfCraft: repo `storytold/pdfcraft`, crates and binaries `pdfcraft*`, bundle id `ai.storyteller.pdfcraft`. On first launch the app moves the old PrintCraft settings and crash-recovery folders to the new name, so upgrades keep recent files, preferences and unsaved work. No feature change. ≈ 30–35%.
- **2026-10-07 (community, #163):** Fill & Sign shows saved signature/initials previews with add, change and remove controls in the quick picker and left panel. The editor loads the saved typed text or drawn strokes; Apply replaces future placements and Cancel preserves the saved value. Existing annotations stay unchanged. Long typed names shrink to the placement width and include glyph overhangs in preview/PDF bounds. Regression coverage includes persistence, removal/recreation, long names and the opt-in UI control channel. Parity counts and effort estimate unchanged (50.2% shipped, ≈ 30–35% effort).
- **2026-10-07 (fork, default-workspace-mode):** Preferences ▸ Documents and view remembers the workspace used for newly opened PDFs. All Tools remains the default; explicit CLI/control mode overrides last for the session and PDF Initial View metadata stays independent. Overall estimate unchanged (≈ 30–35%).
- **2026-10-07 (community, M14, #112):** Simplified Chinese (简体中文, `zh-hans`) as a catalog of about 1,600 entries; `zh`, `zh_CN` and `zh-Hans` locales follow it. In Simplified Chinese the craft-fonts `Hans` faces come before the Japanese ones, so Simplified-only characters don't sit on a different baseline.
- **2026-10-07 (community, M14, #140):** Traditional Chinese (繁體中文, `zh-hant`, Taiwan vocabulary) as a catalog of about 1,800 entries; `zh_TW`, `zh_HK`, `zh_MO` and `zh-Hant` locales follow it. Drawn with the craft-fonts CJK faces (missing characters show replacement boxes).
- **2026-10-07 (community, M14, #141):** Every dialog, panel, notice, shortcut list and native-picker title goes through the catalog (`tl!`, `i18n::fmt`), and Japanese grows from 49 to about 1,800 entries (`ja.tsv`); worker threads (export, OCR, actions) write their summaries in the UI language; history labels keep captured file and field names; the command palette searches translated and English labels and ids. Engine and OS error text is no longer matched against English templates: it is shown as is, inside a translated frame. Localization remains partial for other languages.
@ -253,7 +354,6 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- **2026-10-06 (session 14):** Issues: move and resize existing text boxes in Edit text (#59, closes #40); Windows release builds open no console window and the Start Menu shortcut has its icon (#58, #57; rolled out to every Craft app); native Windows ARM64 installers, installed and run on an ARM64 runner in CI (#66, #71; the intermittent ARM64 test crash was a WARP shader-JIT race between parallel GPU-rendering tests, not our code); XFA forms are detected and explained instead of failing silently (#65, #60 stays open for real XFA). Also: squiggly underline tool, comment opacity, Preferences ▸ Identity, drag-and-drop/hover tests; community table export to Word/HTML/RTF (#62, with linear-time detection and a column cap). #63 (timestamps/LTV with outbound network) held for review fixes and an owner decision on network access. ≈ 30–35% (unchanged).
- **2026-10-06 (community, #68):** Interface language: Czech (Čeština, `ui.set language=cs`) joins English and Japanese. It translates every registered File/Edit/Pages/View/Help command and every label routed through the translation table, with tests that fail when a menu label lacks Czech; the interface fonts are tested to cover Czech diacritics. Dialogs and panels remain English (misc.prefs-language: partial).
- **2026-10-06:** M1: invalid object-number ranges in xref tables and streams take the existing reconstruction path, with the reason recorded, instead of overflowing or aliasing another object. Synthetic open/repair regression added. Overall estimate unchanged (≈ 30–35%).
- **2026-10-05 (session 13, later):** FreeBSD: CI builds and tests the workspace in a FreeBSD 14.3 VM (#45) and the release ships a FreeBSD x86_64 tarball, checked in CI (#42); README lists FreeBSD. Fuzzing: the nightly job's 11 hangs fixed (#41: our parser's exponential retries, inline images, Type 3 fan-out), plus a JBIG2 hang (#43, vendored hayro-jbig2) and a Type 1 font stack overflow (#46, skrifa 0.47); a full run now finds 0 crashes. Landed community PRs #36 (text editing, Japanese replacements), #47 (English/Japanese interface) and #48 (XFDF colour crash). Windows launch crash with Intel's Vulkan driver fixed by using Direct3D 12 (#49, issue #37); one copy of the Japanese font instead of two (#51, −8.3 MB); full screen and About get real tests (#50). P0 88%, P1 52%. ≈ 30–35%.
- **2026-10-05 (community, #47):** Interface language: Preferences ▸ Interface language switches English/Japanese and persists (`ui.set language=ja|en`, reported by `ui.state`). Core menu labels are translated; dialogs, panels and more languages remain open (misc.prefs-language: partial).
- **2026-10-05 (session 13):** Landed community PRs #3–#7 and the never-crash rollout (#11–#25); CI green on every job for the first time in 30+ runs (clippy 1.99, Windows line endings, perf-test noise); nightly fuzz job made to finish (child memory cap) and its findings fixed: 7 out-of-memory crashes (#27, #29, #30) and 11 hangs (in review); issue #8 (integrated GPU, keyboard save prompt); Help ▸ Check for updates (manual only). Added the honest assessment and gap list above. P0 88%, P1 52%; 804 features 49.5% shipped (53.3% weighted). ≈ 30–35%.
@ -261,7 +361,6 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- **2026-10-03 (session 12):** Edit text and images in place (lines, paragraphs, formatting, existing images), Scan & OCR (new `ocr` crate on ocrs with CC-BY-SA models fetched by `cargo xtask models`: searchable image, page ranges, multiple files), Acrobat JavaScript (new `js` crate on boa: custom field scripts in Acrobat's event order, button scripts, document JavaScripts, console, Enable JavaScript preference, sandbox limits). 110+ tools; P0 88%, P1 48%; 803 features 47.9% shipped (51.1% weighted). ≈ 37%.
- **2026-10-02 (session 11, later):** Sign with macOS Keychain identities, Add alternate text, Advanced Search panel, space audit and link clean-up in the Optimizer, hybrid-reference files, signed documents protected from rewrites, redaction cleans the tags, custom stamps, image fields, Combine files with page choice. 97 tools; P0 82%, P1 45%. ≈ 30%.
- **2026-10-02 (session 11):** Accessibility Checker (new `a11y` crate: all 32 rules, report, fixes, options dialog and results panel), MCP resources for page images and text, revisions (list, open an earlier one), export all images, fit visible, form Preview and locked fields, document title in the window; plus attachment comments, eraser, date picker, duplicate/align/distribute fields. 91 tools; P0 80%, P1 43%. ≈ 29%.
- **2026-10-02 (session 10, last):** Manual tab order, backgrounds/watermarks from files (PDF pages as forms, images), Initial View and reading options (honoured on open), bookmarks expand/collapse, make-default comment properties, required-field borders, cut/copy/paste pages, marquee zoom, snapshot. 85 tools; P0 80%, P1 ≈ 27%.
- **2026-10-02 (session 10, latest):** PDF Optimizer and real Reduce File Size (new `optimize` crate: effective-resolution image downsampling, recompression, discards), Field Properties ▸ Options for every field type, Redaction Properties overlay styling, drag-to-reorder pages, repair notice and log; signature revalidation made incremental (comment edit on a 120 MB signed file 84 → 6 ms). 84 tools; P0 parity ≈ 80%. ≈ 27%.
- **2026-10-02 (session 10, later):** Digital signatures (new `sign` crate: PKCS #12, X.509, CMS; PAdES B-B signing incl. certification; validation with trust store and DocMDP-aware change detection; Signatures panel, message bar, sign and digital ID dialogs; signed documents keep incremental saves), polygon/cloud/connected-lines/callout/caret comments, Create PDF from clipboard, comment filters by colour and checkmark. Verified against pdfsig and OpenSSL. 83 tools; P0 parity 78%, P1 22.5%. ≈ 26%.
@ -282,14 +381,12 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- Fixed: the Hand tool did not pan.
- 50 agent tools.
- Overall ≈ 16%.
- **2026-10-01 (session 7):**
- Bookmark editing and page numbering (Number pages), with undo, agent tools and UI.
- A render watchdog: pathological pages are skipped after 20 s instead of spinning forever.
- The agent control channel no longer reports success when the window is hidden.
- Community links: Discord button in the title bar, plus Home, About, Help menu, CLI and README.
- Overall ≈ 10%.
- **2026-10-01 (session 6):**
- UI control channel: agents can inspect the widget tree, click, type, press keys, run commands and take screenshots of the running app. Off unless the app is started with `--control`; loopback only, token-authenticated.
- Combine and insert store identical fonts, images and other resources once. 40 copies of the showcase: 130 MB → 3.9 MB, 3× faster, pixel-identical.
@ -297,14 +394,12 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- Fuzzing (`cargo xtask fuzz`, nightly in CI): about 150,000 mutated files tried. Seven crash and hang bugs found and fixed, each with a regression test: two in our code, five in the temporary renderer.
- Parity checklist: 826 Acrobat Pro features tracked in `parity/acrobat-features.toml`; `cargo xtask parity` checks every claim against code and tests. 11.8% shipped overall, 31.6% of P0.
- Overall ≈ 9.5%.
- **2026-09-30 (session 5, after a machine crash; no work lost):**
- Agent control: new `pdfcraft-automation` crate with 20 JSON-Schema tools, an opt-in MCP server (`pdfcraft-cli mcp`, stdio only, can be compiled out), and `pdfcraft-cli run`/`tools`.
- Text on rotated pages now reads along its lines; the pdf.js oracle is unchanged at median 0.980.
- Finding text in 520 pages: 17.9 s → 3.7 s (parallel), about 20 ms when repeated (cached).
- CI: dependency licence audit (`deny.toml`, `xtask deny`) and a GitHub workflow for macOS, Windows, Linux and wasm.
- Overall ≈ 8%.
- **2026-09-30 (session 4):**
- Combine, extract and split, with links, named destinations, fields, layers, attachments and nested bookmarks all carried over. Combined pages render pixel-identical to their sources.
- Fixed a precision bug in how reals were written.
@ -326,3 +421,27 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- **2026-09-30 (session 1):** planning complete; viewer vertical slice.
- M13: common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters.
- **2026-10-10 (UI docking):** Tools and Inspector can be regrouped, split beside the PDF, or floated inside the app window. Existing toggles reopen panels at their saved locations; UI commands arrange/reset panels, and preferences retain their layout. Read/full-screen modes preserve the focused document view. No milestone percentage change.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. |
| 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged |
| 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. |
| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. |
| 2026-10-11 | minor | M10: borderless inferred tables and positioned invoice watermarks (#773); estimates unchanged. |
| 2026-10-11 | minor | M3: Windows' Animation effects setting is followed (wheel scrolling and zooming without easing, no other animations), and Preferences has Animation effects (Use system setting, On, Off). Estimates unchanged. |
| 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. |
| 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged |
| 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged |
| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) |
| 2026-10-10 | minor | Essentials-user score added (≈ 66%) |
| 2026-10-10 | minor | Gate consistency check: convert and share re-judged partial, not blocking; stage pre-alpha → alpha |
| 2026-10-10 | minor | Second look: ready 40% → 47% (cross-cutting double count removed), mainstream-practitioner numbers and user sentiment added; core-workflow alpha gate applied, stage alpha → pre-alpha |
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated |
| 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h |
| 2026-10-05 | major | Honest assessment and "Where we're lacking" added |
- **2026-10-12 (M8):** Redaction cleans indirect and form structure references, preserving surviving form tags.

View File

@ -16,6 +16,7 @@ workspace = true
pdfcraft-render = { workspace = true }
serde_json = { workspace = true }
pdfcraft-engine = { workspace = true }
pdfcraft-audit = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-automation = { workspace = true }
base64 = "0.22"

View File

@ -0,0 +1,61 @@
//! Friendly flags for the same folder-job tool exposed by `run` and MCP.
use serde_json::json;
use crate::{CliError, OptionSpec, automation, flag, print_output, validate_options};
const OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--root", takes_value: true },
OptionSpec { name: "--out-dir", takes_value: true },
OptionSpec { name: "--steps", takes_value: true },
OptionSpec { name: "--recursive", takes_value: false },
OptionSpec { name: "--stop-on-error", takes_value: false },
OptionSpec { name: "--ppi", takes_value: true },
OptionSpec { name: "--quality", takes_value: true },
OptionSpec { name: "--dpi", takes_value: true },
OptionSpec { name: "--language", takes_value: true },
OptionSpec { name: "--ocr-all-pages", takes_value: false },
];
pub(super) fn run(args: &[String]) -> Result<(), CliError> {
validate_options("batch", args, OPTIONS)?;
let mut paths = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut i = 0;
while let Some(arg) = args.get(i) {
if let Some(spec) = OPTIONS.iter().find(|s| s.name == arg) {
if !seen.insert(spec.name) {
return Err(format!("batch: {} may only be supplied once", spec.name).into());
}
if spec.takes_value {
if args.get(i + 1).is_none_or(|v| v.is_empty() || v.starts_with("--")) {
return Err(format!("batch: {} needs a value", spec.name).into());
}
i += 1;
}
} else {
paths.push(arg);
}
i += 1;
}
let steps: Vec<_> = flag(args, "--steps").ok_or("batch: missing --steps (convert, ocr, optimize)")?.split(',').collect();
let out = flag(args, "--out-dir").ok_or("batch: missing --out-dir")?;
let mut params =
json!({"paths":paths,"steps":steps,"out_dir":out,"recursive":seen.contains("--recursive"),"stop_on_error":seen.contains("--stop-on-error")});
for (option, key) in [("--ppi", "ppi"), ("--quality", "quality"), ("--dpi", "dpi")] {
if let Some(v) = flag(args, option) {
params[key] = serde_json::from_str(v).map_err(|_| format!("batch: {option} needs a number"))?;
}
}
if let Some(lang) = flag(args, "--language") {
params["language"] = json!(lang);
}
if seen.contains("--ocr-all-pages") {
params["skip_text_pages"] = json!(false);
}
let mut auto = automation(args)?;
let content = auto.call("doc_batch", &params).map_err(|e| e.to_string())?;
let failed = content.iter().any(|c| matches!(c, pdfcraft_automation::Content::Json(v) if v["failed"].as_u64().is_some_and(|n| n > 0)));
print_output(&auto, content, None)?;
if failed { Err("batch: one or more files failed; see the JSON report".into()) } else { Ok(()) }
}

View File

@ -17,6 +17,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod batch;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode, Stdio};
@ -51,10 +53,15 @@ pdfcraft-cli render <file.pdf> --page N [--dpi 96] --out x.png (.png, .jpg, .t
pdfcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
pdfcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
[--insert-blank 1] [--title T] [--author A] [--full]
(options run in order; page numbers refer to the document after the earlier options)
pdfcraft-cli combine <a.pdf> <b.pdf> … --out combined.pdf
pdfcraft-cli extract <in.pdf> --pages 1,3,5 --out out.pdf
pdfcraft-cli split <in.pdf> (--every N | --before 3,7) [--out-dir DIR]
pdfcraft-cli check <files or dirs…> [--timeout 20] [--dpi 36] [--json out.json]
pdfcraft-cli audit <file.pdf> [--password PW] find faux redactions (fails when found)
pdfcraft-cli batch <files or dirs...> --steps convert,ocr,optimize --out-dir DIR
[--recursive] [--root DIR] [--stop-on-error]
[--ppi 150] [--quality 60] [--dpi 300] [--language en] [--ocr-all-pages]
pdfcraft-cli tools automation tools and their JSON Schemas
pdfcraft-cli run <tool> [key=value …] [--root DIR] [--out image.png]
pdfcraft-cli run --script steps.json [--root DIR] [{\"tool\": \"doc_open\", \"args\": {…}}, …]
@ -85,8 +92,10 @@ fn main() -> ExitCode {
Some("split") => split(&args[1..]),
Some("check") => check(&args[1..]),
Some("check-one") => check_one(&args[1..]),
Some("audit") => audit(&args[1..]),
Some("tools") => tools(),
Some("run") => run(&args[1..]),
Some("batch") => batch::run(&args[1..]),
Some("ui") => ui(&args[1..]),
#[cfg(feature = "mcp")]
Some("mcp") => mcp(&args[1..]),
@ -139,17 +148,115 @@ fn stdout_line(line: std::fmt::Arguments<'_>) -> Result<(), CliError> {
}
fn version() -> Result<(), CliError> {
stdout_line(format_args!("pdfcraft-cli {}", env!("CARGO_PKG_VERSION")))?;
stdout_line(format_args!("pdfcraft-cli {}", pdfcraft_engine::build_info::version()))?;
stdout_line(format_args!("Discord: {} (help and feedback)", pdfcraft_engine::links::DISCORD))?;
stdout_line(format_args!("Web: {}", pdfcraft_engine::links::APP_PAGE))?;
stdout_line(format_args!("Source: {}", pdfcraft_engine::links::GITHUB))?;
Ok(())
}
/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are
/// found so scripts and CI can gate on it, and when the audit itself cannot run.
fn audit(args: &[String]) -> Result<(), CliError> {
validate_options("audit", args, AUDIT_OPTIONS)?;
let path = *positional(args).first().ok_or("audit: missing file")?;
let bytes = read(path)?;
let doc = pdfcraft_cos::Document::open_with_password(bytes, flag(args, "--password")).map_err(|e| format!("{path}: {e}"))?;
let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
let items: Vec<serde_json::Value> = findings
.into_iter()
.map(|f| {
serde_json::json!({
"page": f.page + 1,
"kind": f.kind.id(),
"rect": f.rect,
"covered_text": f.covered_text,
})
})
.collect();
println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() }));
if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) }
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
}
#[derive(Clone, Copy)]
struct OptionSpec {
name: &'static str,
takes_value: bool,
}
/// Refuse unknown options and malformed confinement flags before any file is read.
fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> {
if specs.iter().any(|spec| spec.name == "--root") {
// `flag` searches every token, including another option's value. Match that lookup so
// a malformed confinement flag cannot be hidden there and fall back to no root.
let mut root_seen = false;
for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") {
if root_seen {
return Err(format!("{command}: --root may only be supplied once").into());
}
root_seen = true;
if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) {
return Err(format!("{command}: --root needs a directory value").into());
}
}
}
let mut i = 0;
while let Some(arg) = args.get(i) {
if let Some(name) = arg.strip_prefix("--") {
let Some(spec) = specs.iter().find(|spec| spec.name == arg) else {
return Err(format!("{command}: unknown option --{name}").into());
};
// Other missing or malformed values are left to the command, which reports them in its own
// terms (`text` and `extract` explain what a page value must be).
i += if spec.takes_value { 2 } else { 1 };
} else {
i += 1;
}
}
Ok(())
}
const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }];
const TEXT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }];
const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }];
const EXTRACT_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--out", takes_value: true },
OptionSpec { name: "--pages", takes_value: true },
OptionSpec { name: "--password", takes_value: true },
];
const SPLIT_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--every", takes_value: true },
OptionSpec { name: "--before", takes_value: true },
OptionSpec { name: "--out-dir", takes_value: true },
OptionSpec { name: "--password", takes_value: true },
];
const RENDER_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--page", takes_value: true },
OptionSpec { name: "--dpi", takes_value: true },
OptionSpec { name: "--out", takes_value: true },
OptionSpec { name: "--password", takes_value: true },
];
const CHECK_ONE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }];
const CHECK_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--timeout", takes_value: true },
OptionSpec { name: "--dpi", takes_value: true },
OptionSpec { name: "--json", takes_value: true },
];
const RUN_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--root", takes_value: true },
OptionSpec { name: "--script", takes_value: true },
OptionSpec { name: "--out", takes_value: true },
];
const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }];
#[cfg(feature = "mcp")]
const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }];
fn positional(args: &[String]) -> Vec<&str> {
let mut out = Vec::new();
let mut skip = false;
@ -172,6 +279,7 @@ fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
}
fn info(args: &[String]) -> Result<(), CliError> {
validate_options("info", args, INFO_OPTIONS)?;
let path = *positional(args).first().ok_or("info: missing file")?;
let bytes = read(path)?;
let password = flag(args, "--password");
@ -205,6 +313,7 @@ fn info(args: &[String]) -> Result<(), CliError> {
}
fn text(args: &[String]) -> Result<(), CliError> {
validate_options("text", args, TEXT_OPTIONS)?;
let path = *positional(args).first().ok_or("text: missing file")?;
let mut selected_page = None;
// Validate every supplied value before reading, retaining the first valid selection.
@ -229,18 +338,27 @@ fn text(args: &[String]) -> Result<(), CliError> {
// no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask
// `inspect` why instead, so a protected file fails the same way `info` does.
if r.page_count() == 0 {
let info = inspect(bytes, password).map_err(|e| format!("text: {e}"))?;
let info = inspect(bytes.clone(), password).map_err(|e| format!("text: {e}"))?;
if !info.pages.is_empty() {
return Err("text: the document could not be parsed".into());
}
}
// Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
// permissions password, a document that doesn't allow copying gives no text.
let mut session = pdfcraft_engine::Session::new();
if let Some(doc) = session.open(path, None, bytes, password).ok().and_then(|id| session.get(id)) {
// The engine's hint is about editing in the app; on the command line the way in is
// `--password`.
doc.check_copying()
.map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
}
let pages: Vec<usize> = match selected_page {
Some(page) => vec![page],
None => (0..r.page_count()).collect(),
};
let mut failed: Vec<usize> = Vec::new();
for (n, p) in pages.iter().enumerate() {
let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false });
if let Some(e) = out.error {
let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1);
failed.push(p + 1);
@ -326,6 +444,28 @@ fn edit(args: &[String]) -> Result<(), CliError> {
let mut session = Session::new();
let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?;
for e in edits {
// A destination past the end must fail like `page_move` / `page_insert_blank` do, not
// silently become an append (#1330). Checked per edit: earlier edits change the count.
let n = session.get(id).map_or(0, |d| d.info.pages.len());
let destination = match &e {
Edit::MovePages { to, .. } => Some(("--move target", *to)),
Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)),
_ => None,
};
if let Some((option, at)) = destination.filter(|(_, at)| *at > n) {
return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into());
}
// Pages count from the document as the earlier options left it, so a page past the end is
// reported with its own option and the current page count (#1550).
let listed = match &e {
Edit::RotatePages { pages, .. } => Some(("--rotate", pages)),
Edit::DeletePages { pages } => Some(("--delete", pages)),
Edit::MovePages { pages, .. } => Some(("--move", pages)),
_ => None,
};
if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) {
return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into());
}
session.apply(id, e).map_err(|e| e.to_string())?;
}
let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?;
@ -337,6 +477,7 @@ fn file_stem(path: &str) -> String {
}
fn combine(args: &[String]) -> Result<(), CliError> {
validate_options("combine", args, COMBINE_OPTIONS)?;
let out = flag(args, "--out").ok_or("combine: missing --out")?;
let inputs = positional(args);
if inputs.len() < 2 {
@ -348,6 +489,7 @@ fn combine(args: &[String]) -> Result<(), CliError> {
}
fn extract(args: &[String]) -> Result<(), CliError> {
validate_options("extract", args, EXTRACT_OPTIONS)?;
let path = *positional(args).first().ok_or("extract: missing file")?;
let out = flag(args, "--out").ok_or("extract: missing --out")?;
let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?;
@ -358,6 +500,7 @@ fn extract(args: &[String]) -> Result<(), CliError> {
}
fn split(args: &[String]) -> Result<(), CliError> {
validate_options("split", args, SPLIT_OPTIONS)?;
use pdfcraft_engine::SplitBy;
let path = *positional(args).first().ok_or("split: missing file")?;
let by = match (flag(args, "--every"), flag(args, "--before")) {
@ -382,6 +525,7 @@ fn split(args: &[String]) -> Result<(), CliError> {
}
fn render(args: &[String]) -> Result<(), CliError> {
validate_options("render", args, RENDER_OPTIONS)?;
let path = *positional(args).first().ok_or("render: missing file")?;
let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?;
let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?;
@ -395,8 +539,11 @@ fn render(args: &[String]) -> Result<(), CliError> {
Some("pam") => None,
_ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()),
};
let mut r = PageRenderer::new(read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), ..Default::default() });
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
let mut r = PageRenderer::new(
read(path)?,
RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() },
);
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
if let Some(e) = p.error {
return Err(e.into());
}
@ -411,11 +558,19 @@ fn render(args: &[String]) -> Result<(), CliError> {
};
std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?;
let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis);
for warning in &p.warnings {
match warning {
pdfcraft_render::RenderWarning::ContentTruncated => {
let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped");
}
}
}
Ok(())
}
/// Child-process body for `check`: prints one JSON line.
fn check_one(args: &[String]) -> Result<(), CliError> {
validate_options("check-one", args, CHECK_ONE_OPTIONS)?;
let path = *positional(args).first().ok_or("check-one: missing file")?;
let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?;
let start = Instant::now();
@ -426,8 +581,8 @@ fn check_one(args: &[String]) -> Result<(), CliError> {
let mut r = PageRenderer::new(bytes, RenderConfig::default());
let mut failed = Vec::new();
for i in 0..info.pages.len().min(500) {
let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }).error {
let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error {
failed.push(format!("p{} text: {e}", i + 1));
}
if let Some(e) = p.error {
@ -482,8 +637,20 @@ fn short(s: &str) -> String {
fn collect(paths: &[&str]) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
let mut visited = std::collections::HashSet::new();
while let Some(p) = stack.pop() {
if p.is_dir() {
// Directory aliases and symlink cycles must not scan the same tree repeatedly.
let identity = match p.canonicalize() {
Ok(identity) => identity,
Err(error) => {
eprintln!("{}: could not resolve directory: {error}", p.display());
continue;
}
};
if !visited.insert(identity) {
continue;
}
if let Ok(rd) = std::fs::read_dir(&p) {
stack.extend(rd.flatten().map(|e| e.path()));
}
@ -496,6 +663,7 @@ fn collect(paths: &[&str]) -> Vec<PathBuf> {
}
fn check(args: &[String]) -> Result<(), CliError> {
validate_options("check", args, CHECK_OPTIONS)?;
let files = collect(&positional(args));
if files.is_empty() {
return Err("check: no PDF files found".into());
@ -600,6 +768,7 @@ fn print_output(auto: &pdfcraft_automation::Automation, content: Vec<pdfcraft_au
}
fn run(args: &[String]) -> Result<(), CliError> {
validate_options("run", args, RUN_OPTIONS)?;
let mut auto = automation(args)?;
if let Some(script) = flag(args, "--script") {
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
@ -627,6 +796,7 @@ fn run(args: &[String]) -> Result<(), CliError> {
#[cfg(feature = "mcp")]
fn mcp(args: &[String]) -> Result<(), CliError> {
validate_options("mcp", args, MCP_OPTIONS)?;
let compact = args.iter().any(|a| a == "--compact");
let mut server = pdfcraft_automation::mcp::McpServer::new(automation(args)?).with_compact(compact);
let _ = writeln!(
@ -642,6 +812,7 @@ fn mcp(args: &[String]) -> Result<(), CliError> {
/// One request to a running app's control channel (`pdfcraft --control FILE`).
fn ui(args: &[String]) -> Result<(), CliError> {
validate_options("ui", args, UI_OPTIONS)?;
use std::io::{BufRead, BufReader, Write as _};
let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?;
let info = read_control_file(file)?;

View File

@ -0,0 +1,46 @@
use serde_json::Value;
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
#[test]
fn cli_batch_reports_partial_failure_and_continues_into_subfolders() {
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-batch-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("input/sub")).unwrap();
std::fs::write(dir.join("input/0.pdf"), b"broken").unwrap();
std::fs::write(dir.join("input/sub/page.txt"), "Batch text").unwrap();
let output = Command::new(BIN)
.args(["batch", "input", "--root"])
.arg(&dir)
.args(["--steps", "convert,optimize", "--out-dir", "out", "--recursive"])
.output()
.unwrap();
assert!(!output.status.success());
let report: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(report["completed"], 1, "{report}");
assert_eq!(report["failed"], 1);
let converted = dir.join("out/sub/page.pdf");
assert!(converted.exists());
let text = Command::new(BIN).arg("text").arg(converted).output().unwrap();
assert!(text.status.success());
assert!(String::from_utf8_lossy(&text.stdout).contains("Batch text"));
let success = Command::new(BIN).current_dir(&dir).args(["batch", "input/sub", "--steps", "convert", "--out-dir", "other"]).output().unwrap();
assert!(success.status.success(), "{}", String::from_utf8_lossy(&success.stderr));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn cli_batch_rejects_bad_flags_before_creating_outputs() {
for args in [
vec!["batch", "--steps"],
vec!["batch", "--recursive", "--root"],
vec!["batch", "--steps", "convert", "--out-dir"],
vec!["batch", "--steps", "convert", "--steps", "ocr", "--out-dir", "out"],
vec!["batch", "--unknown"],
] {
let output = Command::new(BIN).args(args).output().unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
}
}

View File

@ -0,0 +1,111 @@
//! The robustness harness visits each directory once, including symlink aliases and cycles.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
struct Workdir(PathBuf);
impl Workdir {
fn new(test: &str) -> Self {
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-check-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("input/nested")).unwrap();
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 20 30] >>",
"<< /Type /Page /Parent 2 0 R >>",
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n");
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
std::fs::write(dir.join("input/a.pdf"), &bytes).unwrap();
std::fs::write(dir.join("input/nested/b.PDF"), &bytes).unwrap();
Self(dir)
}
fn check(&self, inputs: &[&Path]) -> Vec<serde_json::Value> {
let report = self.0.join("report.json");
let mut child = Command::new(BIN)
.arg("check")
.args(inputs)
.args(["--dpi", "36", "--timeout", "2", "--json"])
.arg(&report)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
while child.try_wait().unwrap().is_none() {
if Instant::now() >= deadline {
child.kill().unwrap();
let output = child.wait_with_output().unwrap();
panic!("check did not finish scanning directories: {}", String::from_utf8_lossy(&output.stderr));
}
std::thread::sleep(Duration::from_millis(10));
}
let output = child.wait_with_output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
serde_json::from_slice(&std::fs::read(report).unwrap()).unwrap()
}
}
impl Drop for Workdir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn assert_two_valid_pdfs(results: &[serde_json::Value]) {
assert_eq!(results.len(), 2, "each PDF should be checked once: {results:?}");
for result in results {
assert_eq!(result["status"], "ok", "{result}");
assert_eq!(result["pages"], 1, "{result}");
}
assert!(results[0]["file"].as_str().unwrap().ends_with("a.pdf"));
assert!(results[1]["file"].as_str().unwrap().ends_with("b.PDF"));
}
#[test]
fn check_scans_overlapping_directories_once() {
let dir = Workdir::new("overlap");
let input = dir.0.join("input");
assert_two_valid_pdfs(&dir.check(&[&input, &input, &input.join("nested")]));
}
#[cfg(unix)]
#[test]
fn check_scans_a_directory_symlink_cycle_once() {
let dir = Workdir::new("cycle");
let input = dir.0.join("input");
std::os::unix::fs::symlink(".", input.join("again")).unwrap();
assert_two_valid_pdfs(&dir.check(&[&input]));
}
#[cfg(unix)]
#[test]
fn check_follows_directory_and_file_symlinks() {
let dir = Workdir::new("aliases");
let input = dir.0.join("input");
let alias = dir.0.join("alias");
std::os::unix::fs::symlink(&input, &alias).unwrap();
assert_two_valid_pdfs(&dir.check(&[&input, &alias]));
let file_alias = dir.0.join("page.pdf");
std::os::unix::fs::symlink(input.join("a.pdf"), &file_alias).unwrap();
let results = dir.check(&[&file_alias]);
assert_eq!(results.len(), 1);
assert_eq!(results[0]["status"], "ok");
assert_eq!(results[0]["file"], file_alias.to_string_lossy().as_ref());
}

View File

@ -179,3 +179,63 @@ mod one_based_arguments {
assert_eq!(std::fs::read(dir.0.join("input.pdf")).unwrap(), source);
}
}
/// A destination past the end must fail, not silently become an append (#1330).
#[test]
fn a_destination_past_the_end_fails_and_writes_nothing() {
let input = tmp("past-end", "in.pdf");
std::fs::write(&input, fixture()).unwrap();
let output = tmp("past-end", "out.pdf");
for (option, value, diagnostic) in [
("--insert-blank", "3", "--insert-blank 3 is past the end: it must be between 1 and 2 (the document has 1 pages)"),
("--insert-blank", "99", "--insert-blank 99 is past the end: it must be between 1 and 2 (the document has 1 pages)"),
("--move", "1:3", "--move target 3 is past the end: it must be between 1 and 2 (the document has 1 pages)"),
("--move", "1:99", "--move target 99 is past the end: it must be between 1 and 2 (the document has 1 pages)"),
] {
let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap(), option, value]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{option} {value}");
assert_eq!(String::from_utf8_lossy(&out.stderr), format!("pdfcraft-cli: {diagnostic}\n"), "{option} {value}");
assert!(!output.exists(), "{option} {value}: no output may be written");
}
}
/// One past the last page appends, and each destination is checked against the page count at that point.
#[test]
fn the_append_destination_and_counts_grown_by_earlier_edits_are_accepted() {
let input = tmp("append", "in.pdf");
std::fs::write(&input, fixture()).unwrap();
for (name, args) in [
("insert.pdf", vec!["--insert-blank", "2"]),
("move.pdf", vec!["--insert-blank", "1", "--move", "1:3"]),
("grown.pdf", vec!["--insert-blank", "2", "--insert-blank", "3"]),
] {
let output = tmp("append", name);
let mut all = vec!["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap()];
all.extend(args);
let out = Command::new(BIN).args(&all).output().unwrap();
assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
let info = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(&output).unwrap()), None).unwrap();
let sizes: Vec<_> = info.pages.iter().map(|p| (p.width, p.height)).collect();
let expected: &[(f32, f32)] = match name {
"insert.pdf" => &[(200.0, 300.0), (612.0, 792.0)],
"move.pdf" => &[(200.0, 300.0), (612.0, 792.0)],
_ => &[(200.0, 300.0), (612.0, 792.0), (612.0, 792.0)],
};
assert_eq!(sizes, expected, "{name}");
}
}
/// A page past the end names its own option and the page count left by the earlier options (#1550).
#[test]
fn a_page_past_the_end_after_earlier_edits_names_that_option() {
let input = tmp("chained", "in.pdf");
std::fs::write(&input, fixture()).unwrap();
let output = tmp("chained", "out.pdf");
// 1 page, grown to 3, then --delete leaves 2: the 3 in --move 3:1 is the page that is gone.
let args = ["--insert-blank", "2", "--insert-blank", "3", "--delete", "3", "--move", "3:1"];
let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap()]).args(args).output().unwrap();
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("--move: page 3 does not exist") && err.contains("has 2 pages"), "{err}");
assert!(!output.exists());
}

View File

@ -37,6 +37,18 @@ fn listed_tools(flags: &[&str]) -> Vec<String> {
reply["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn mcp_rejects_unknown_options_before_starting_the_server() {
for option in ["--totally-bogus-flag", "--rot", "--root=."] {
let out = Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).args(["mcp", option]).output().expect("run CLI");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "mcp {option} unexpectedly succeeded");
assert!(stderr.contains("unknown option") && stderr.contains(option), "mcp {option}: {stderr}");
assert!(!stderr.contains("MCP server on stdio"), "server started despite invalid option: {stderr}");
}
}
#[test]
fn compact_flag_shortens_the_tool_list() {
let full = listed_tools(&[]);

View File

@ -0,0 +1,29 @@
//! Every command rejects misspelled long options before opening or writing a file.
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
#[test]
fn non_edit_commands_reject_unknown_options_before_io() {
for args in [
vec!["info", "missing.pdf", "--passwrod", "secret"],
vec!["text", "missing.pdf", "--pgae", "1"],
vec!["combine", "a.pdf", "b.pdf", "--ot", "out.pdf"],
vec!["extract", "missing.pdf", "--pgaes", "1", "--out", "out.pdf"],
vec!["split", "missing.pdf", "--ever", "2"],
vec!["render", "missing.pdf", "--dppi", "96", "--out", "out.png"],
vec!["check-one", "missing.pdf", "--edti"],
vec!["check", "missing.pdf", "--timout", "2"],
vec!["audit", "missing.pdf", "--passwrod", "x"],
vec!["audit", "missing.pdf", "--nonsense", "1"],
vec!["run", "doc_open", "--roto", "/tmp"],
vec!["ui", "--contro", "control.json", "state"],
] {
let unknown = args.iter().find(|arg| arg.starts_with("--")).expect("test option");
let out = Command::new(BIN).args(&args).output().expect("run cli");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "{args:?} unexpectedly succeeded");
assert!(stderr.contains("unknown option") && stderr.contains(unknown), "{args:?}: {stderr}");
}
}

View File

@ -0,0 +1,121 @@
//! A malformed `--root` must never fall back to an unconfined automation session.
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
const CANARY: &[u8] = b"KEEP EXISTING DESTINATION\n";
struct TestDir(PathBuf);
impl TestDir {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
for _ in 0..128 {
let path = std::env::temp_dir().join(format!("pdfcraft-root-arguments-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
match std::fs::create_dir(&path) {
Ok(()) => return Self(path),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => panic!("creating argument test directory: {error}"),
}
}
panic!("no unused argument test directory after 128 attempts");
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn malformed_root_fails_before_a_script_can_overwrite_a_file() {
let dir = TestDir::new();
let destination = dir.0.join("existing.pdf");
let script = dir.0.join("steps.json");
let steps = serde_json::json!([
{ "tool": "doc_create", "args": { "from": "blank", "width": 100, "height": 100 } },
{ "tool": "doc_save", "args": { "doc": 1, "path": destination } },
]);
std::fs::write(&script, steps.to_string()).unwrap();
std::fs::write(&destination, CANARY).unwrap();
for (flags, diagnostic) in [
(vec!["--root"], "--root needs a directory value"),
(vec!["--root", ""], "--root needs a directory value"),
(vec!["--root", "--out", "escaped.png"], "--root needs a directory value"),
(vec!["--out", "--root"], "--root needs a directory value"),
(vec!["--root", ".", "--root"], "--root may only be supplied once"),
(vec!["--root", ".", "--root", "."], "--root may only be supplied once"),
(vec!["--root", ".", "--out", "--root"], "--root may only be supplied once"),
] {
// Both a valid script and a missing one: argument validation precedes script I/O.
for script_name in ["steps.json", "missing.json"] {
let out = Command::new(BIN).current_dir(&dir.0).args(["run", "--script", script_name]).args(&flags).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{script_name} {flags:?} unexpectedly succeeded");
assert_eq!(String::from_utf8(out.stderr).unwrap().trim(), format!("pdfcraft-cli: run: {diagnostic}"));
assert!(out.stdout.is_empty(), "a refused command must not execute or report tool results");
assert_eq!(std::fs::read(&destination).unwrap(), CANARY);
assert!(!dir.0.join("escaped.png").exists());
}
}
assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 2);
}
#[test]
fn malformed_root_fails_before_direct_tool_argument_validation() {
let out = Command::new(BIN).args(["run", "doc_open", "not-key-value", "--root"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert_eq!(String::from_utf8(out.stderr).unwrap().trim(), "pdfcraft-cli: run: --root needs a directory value");
assert!(out.stdout.is_empty());
}
#[test]
fn a_directory_name_starting_with_a_single_hyphen_remains_valid() {
let dir = TestDir::new();
std::fs::create_dir(dir.0.join("-documents")).unwrap();
let out = Command::new(BIN)
.current_dir(&dir.0)
.args(["run", "doc_create", "from=blank", "width=100", "height=100", "--root", "-documents"])
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(serde_json::from_slice::<serde_json::Value>(&out.stdout).is_ok());
}
#[cfg(feature = "mcp")]
#[test]
fn malformed_root_never_starts_or_initializes_an_mcp_server() {
use std::io::Write as _;
use std::process::Stdio;
let dir = TestDir::new();
// Even if a directory matches the next option's name, it is not a root operand.
std::fs::create_dir(dir.0.join("--compact")).unwrap();
for (flags, diagnostic) in [
(vec!["--root"], "--root needs a directory value"),
(vec!["--root", ""], "--root needs a directory value"),
(vec!["--root", "--compact"], "--root needs a directory value"),
(vec!["--compact", "--root"], "--root needs a directory value"),
(vec!["--root", ".", "--root"], "--root may only be supplied once"),
(vec!["--root", ".", "--root", "./--compact"], "--root may only be supplied once"),
] {
let mut child = Command::new(BIN)
.current_dir(&dir.0)
.arg("mcp")
.args(&flags)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
// Early rejection may close stdin before this write; that is the intended behavior.
let _ = child.stdin.take().unwrap().write_all(b"{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\",\"params\":{}}\n");
let out = child.wait_with_output().unwrap();
assert_eq!(out.status.code(), Some(1), "{flags:?} unexpectedly started the server");
assert_eq!(String::from_utf8(out.stderr).unwrap().trim(), format!("pdfcraft-cli: mcp: {diagnostic}"));
assert!(out.stdout.is_empty(), "a refused server must not answer initialize");
}
}

View File

@ -96,6 +96,41 @@ fn text_of_a_protected_document_with_the_password_succeeds() {
assert!(stdout.contains("Page 1") && stdout.contains("Page 3"), "{stdout}");
}
/// `three.pdf` encrypted so that the open password `openme` doesn't allow copying, and the
/// permissions password `boss` lifts that.
fn copy_protected_fixture() -> std::path::PathBuf {
let src = tmp("to-protect-no-copy.pdf");
std::fs::write(&src, fixture(3)).unwrap();
let out_path = tmp("protected-no-copy.pdf");
let script = tmp("protect-no-copy.json");
let steps = serde_json::json!([
{ "tool": "doc_open", "args": { "path": src } },
{ "tool": "doc_protect", "args": { "doc": 1, "open_password": "openme", "permissions_password": "boss", "copy": false } },
{ "tool": "doc_save", "args": { "doc": 1, "path": out_path } },
]);
std::fs::write(&script, steps.to_string()).unwrap();
let out = Command::new(BIN).args(["run", "--script", script.to_str().unwrap()]).output().unwrap();
assert!(out.status.success(), "protecting the fixture failed: {}", String::from_utf8_lossy(&out.stderr));
out_path
}
/// `text` copies text out, so it asks what `text_extract` asks (#1645): the open password alone
/// gives no text when copying isn't allowed, the permissions password does.
#[test]
fn text_respects_a_document_that_does_not_allow_copying() {
let path = copy_protected_fixture();
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "openme"]).output().unwrap();
assert!(!out.status.success(), "copying isn't allowed: {}", String::from_utf8_lossy(&out.stdout));
assert!(out.stdout.is_empty(), "{}", String::from_utf8_lossy(&out.stdout));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("don't allow copying"), "the reason must be named: {stderr}");
assert!(stderr.contains("--password"), "and the way in: {stderr}");
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "boss"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Page 1") && stdout.contains("Page 3"), "{stdout}");
}
mod page_argument_tests {
use super::*;
use std::path::PathBuf;

View File

@ -16,5 +16,5 @@ egui = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] }
js-sys = "0.3"

View File

@ -51,16 +51,55 @@ fn main() {
ctx.request_repaint();
});
}
// eframe never raises a close request on a browser reload or tab close, so `guard_quit` never
// prompts; ask the browser to confirm instead while a document has unsaved work (#812).
warn_before_unload(app.unsaved_flag.clone());
// `?host=parent`: a same-origin page embedding PdfCraft in an iframe (such
// as the Nextcloud app) passes files in and takes saves back (`host` below).
if query_param("host").as_deref() == Some("parent")
&& let Some(bridge) = host::Bridge::new()
{
// `?author=`: the host's name for the user signs their comments,
// unless they chose a name in PdfCraft (the web default is "Guest").
if let Some(author) = query_param("author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty())
&& app.comment_prefs.author == "Guest"
{
app.comment_prefs.author = author.chars().take(200).collect();
}
bridge.listen(app.startup_inbox.clone(), cc.egui_ctx.clone());
app.host_save = Some(bridge.save_fn());
app.host_dirty = Some(bridge.dirty_fn());
bridge.post_ready();
}
Ok(Box::new(app))
}),
)
.await;
if let Err(e) = started {
eframe::web_sys::console::error_1(&e);
if query_param("host").as_deref() == Some("parent")
&& let Some(bridge) = host::Bridge::new()
{
bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}")));
}
}
});
}
#[cfg(target_arch = "wasm32")]
fn warn_before_unload(unsaved: std::sync::Arc<std::sync::atomic::AtomicBool>) {
use eframe::wasm_bindgen::{JsCast, closure::Closure};
let Some(window) = web_sys::window() else { return };
let handler = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
if unsaved.load(std::sync::atomic::Ordering::Relaxed) {
event.prevent_default();
}
});
if window.add_event_listener_with_callback("beforeunload", handler.as_ref().unchecked_ref()).is_ok() {
handler.forget();
}
}
#[cfg(target_arch = "wasm32")]
fn query_param(key: &str) -> Option<String> {
let search = web_sys::window()?.location().search().ok()?;
@ -80,6 +119,132 @@ async fn fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
Ok(js_sys::Uint8Array::new(&buf).to_vec())
}
/// The `postMessage` bridge to a same-origin parent page. Messages are plain objects with a
/// `type`:
///
/// - `pdfcraft:ready` (to the parent): the app is listening; send files now.
/// - `pdfcraft:open` (from the parent): `{ name, bytes }`, where `bytes` is an `ArrayBuffer` or
/// `Uint8Array` holding a PDF. It opens in a tab, like a `?file=` URL.
/// - `pdfcraft:save` (to the parent): `{ name, saveAs, bytes }` when the user saves; `bytes` is
/// a transferred `ArrayBuffer`. The parent stores the file (and reports its own errors).
/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away.
/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start.
///
/// Only the parent window on the app's own origin is heard or answered.
#[cfg(target_arch = "wasm32")]
mod host {
use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure};
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to).
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
Some(Self { window, parent, origin })
}
fn post(&self, message: &js_sys::Object, transfer: Option<&JsValue>) -> Result<(), String> {
let sent = match transfer {
Some(t) => self.parent.post_message_with_transfer(message, &self.origin, &js_sys::Array::of1(t)),
None => self.parent.post_message(message, &self.origin),
};
sent.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
pub fn post_ready(&self) {
if let Some(m) = message(&[("type", "pdfcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]) {
let _ = self.post(&m, None);
}
}
/// The app couldn't start (no WebGL, for example): let the parent say so.
pub fn post_failed(&self, error: &str) {
if let Some(m) = message(&[("type", "pdfcraft:failed".into()), ("error", error.into())]) {
let _ = self.post(&m, None);
}
}
/// Hear `pdfcraft:open` from the parent and queue the file like a `?file=` download.
pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
}
let data = event.data();
if string(&data, "type").as_deref() != Some("pdfcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string());
let Ok(bytes) = js_sys::Reflect::get(&data, &JsValue::from_str("bytes")) else { return };
let bytes = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
array.to_vec()
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
js_sys::Uint8Array::new(&bytes).to_vec()
} else {
return;
};
if let Ok(mut q) = inbox.lock() {
q.push((name, bytes));
}
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn {
let bridge = self.clone();
Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let m =
message(&[("type", "pdfcraft:save".into()), ("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())])
.ok_or_else(|| "couldn't build the message".to_string())?;
bridge.post(&m, Some(&buffer))
})
}
pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn {
let bridge = self.clone();
let mut last = false;
Box::new(move |dirty: bool| {
if dirty != last {
last = dirty;
if let Some(m) = message(&[("type", "pdfcraft:dirty".into()), ("dirty", dirty.into())]) {
let _ = bridge.post(&m, None);
}
}
})
}
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("pdfcraft-web targets wasm32: run `trunk serve` or `trunk build --release` in apps/pdfcraft-web");

View File

@ -14,6 +14,8 @@ workspace = true
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] }
rustls-native-certs = "0.8"
pdfcraft-ui-egui = { workspace = true }
# `build_info`: the version and compile-time build label for `--version` and the log.
pdfcraft-engine = { workspace = true }
# wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or
# driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links"] }
@ -28,6 +30,12 @@ log = { workspace = true }
# behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
[target.'cfg(target_os = "linux")'.dependencies]
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is
# drawn on that one (src/compositor_gpu.rs). Both are already in the tree through winit.
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client"] }
[dev-dependencies]
# Waits for wgpu's adapter, device and error-scope futures in tests (already in the tree through wgpu).
pollster = "1.0"
@ -36,6 +44,14 @@ pollster = "1.0"
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
# Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's
# `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe
# fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it.
windows = { version = "0.62", default-features = false, features = ["std", "UI_ViewManagement"] }
# Single instance (#282): later launches hand their files to the running app over a Unix domain
# socket. `std` has no Unix sockets on Windows, and calling Winsock directly needs unsafe, which
# our workspace forbids. Already in the lockfile through zbus; pinned exactly, review before bumping.
uds_windows = "=1.2.1"
[build-dependencies]
# Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows.

View File

@ -1,19 +1,27 @@
//! Windows only: embed the app icon and version info (VERSIONINFO) into `pdfcraft.exe`, so it
//! shows in Explorer, the taskbar, the Start menu and Alt-Tab.
//! shows in Explorer, the taskbar, the Start menu and Alt-Tab. A separate PDF document icon
//! uses resource ID 2; the installer references it as `pdfcraft.exe,-2`.
//!
//! On every other target this does nothing. A missing resource compiler is a warning, so a
//! cross-compile from macOS or Linux still links, unless `PDFCRAFT_REQUIRE_WINRES=1` turns it
//! into an error (for release builds).
#[path = "src/windows_manifest.rs"]
mod windows_manifest;
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../assets/app-icon/pdfcraft.ico");
println!("cargo:rerun-if-changed=../../assets/app-icon/pdfcraft-document.ico");
println!("cargo:rerun-if-changed=src/windows_manifest.rs");
println!("cargo:rerun-if-env-changed=PDFCRAFT_REQUIRE_WINRES");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
let mut res = winresource::WindowsResource::new();
res.set_icon("../../assets/app-icon/pdfcraft.ico")
.set_icon_with_id("../../assets/app-icon/pdfcraft-document.ico", "2")
.set_manifest(windows_manifest::WINDOWS_MANIFEST)
.set("ProductName", "PdfCraft")
.set("FileDescription", "PdfCraft PDF workbench")
.set("LegalCopyright", "Copyright (c) the PdfCraft contributors. MIT OR Apache-2.0.")

View File

@ -0,0 +1,177 @@
//! The GPU the Wayland compositor draws with.
//!
//! A window drawn on another GPU reaches the compositor as that GPU's buffers, and the compositor
//! may not show them: it can refuse them and end the window's connection (#96), or take them and
//! show nothing, with no error on either side. The second happened on a hybrid laptop whose
//! Hyprland runs on the NVIDIA GPU (`AQ_DRM_DEVICES`) while the built-in panel hangs off the Intel
//! one: the panel rule picked Intel and the window stayed empty. Which GPU drives a display is a
//! guess at the compositor's GPU; the compositor states it, as the `main_device` of its
//! linux-dmabuf feedback (protocol version 4): the device it imports client buffers on.
#[cfg(target_os = "linux")]
use wayland_client::globals::{GlobalListContents, registry_queue_init};
#[cfg(target_os = "linux")]
use wayland_client::protocol::wl_registry::{self, WlRegistry};
#[cfg(target_os = "linux")]
use wayland_client::{Connection, Dispatch, QueueHandle};
#[cfg(target_os = "linux")]
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_feedback_v1::{self, ZwpLinuxDmabufFeedbackV1};
#[cfg(target_os = "linux")]
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_v1::{self, ZwpLinuxDmabufV1};
/// PCI `(vendor, device)` ids of the GPU the Wayland compositor draws with. `None` outside a
/// Wayland session, when the compositor doesn't say (linux-dmabuf older than version 4), when its
/// device isn't a PCI GPU, and on other systems.
#[cfg(target_os = "linux")]
pub(crate) fn compositor_gpu() -> Option<(u32, u32)> {
let device = match main_device() {
Ok(device) => device,
Err(why) => {
log::debug!("the compositor's GPU is unknown: {why}");
return None;
}
};
let pci = pci_ids(std::path::Path::new("/sys/dev/char"), device);
match pci {
Some((vendor, id)) => log::info!("the compositor draws with {vendor:04x}:{id:04x}"),
None => log::debug!("the compositor's device {:?} has no PCI ids", major_minor(device)),
}
pci
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn compositor_gpu() -> Option<(u32, u32)> {
None
}
/// The `main_device` of the compositor's default linux-dmabuf feedback, asked on a connection of
/// its own that is closed again before the window opens.
#[cfg(target_os = "linux")]
fn main_device() -> Result<u64, String> {
let conn = Connection::connect_to_env().map_err(|e| format!("no Wayland compositor: {e}"))?;
let (globals, mut queue) = registry_queue_init::<Feedback>(&conn).map_err(|e| e.to_string())?;
let qh = queue.handle();
// Version 4 brought the feedback; older compositors only list formats.
let dmabuf: ZwpLinuxDmabufV1 = globals.bind(&qh, 4..=4, ()).map_err(|e| format!("linux-dmabuf version 4: {e}"))?;
let _feedback = dmabuf.get_default_feedback(&qh, ());
let mut state = Feedback::default();
// The compositor sends the whole feedback, ending with `done`, in reply; the bound only keeps
// a compositor that never ends it from holding up the start.
for _ in 0..4 {
if state.done {
break;
}
queue.roundtrip(&mut state).map_err(|e| e.to_string())?;
}
// Dropping the connection releases the objects.
let device = state.main_device.ok_or("the compositor named no main device")?;
// A `dev_t`, in this machine's byte order.
let device: [u8; 8] = device.try_into().map_err(|d: Vec<u8>| format!("a {}-byte device number", d.len()))?;
Ok(u64::from_ne_bytes(device))
}
/// What [`main_device`] collects from the feedback events.
#[cfg(target_os = "linux")]
#[derive(Default)]
struct Feedback {
main_device: Option<Vec<u8>>,
done: bool,
}
#[cfg(target_os = "linux")]
impl Dispatch<WlRegistry, GlobalListContents> for Feedback {
fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle<Self>) {}
}
#[cfg(target_os = "linux")]
impl Dispatch<ZwpLinuxDmabufV1, ()> for Feedback {
// Version 4 sends no format or modifier events: the feedback replaces them.
fn event(_: &mut Self, _: &ZwpLinuxDmabufV1, _: zwp_linux_dmabuf_v1::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
}
#[cfg(target_os = "linux")]
impl Dispatch<ZwpLinuxDmabufFeedbackV1, ()> for Feedback {
fn event(
state: &mut Self,
_: &ZwpLinuxDmabufFeedbackV1,
event: zwp_linux_dmabuf_feedback_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
zwp_linux_dmabuf_feedback_v1::Event::MainDevice { device } => state.main_device = Some(device),
zwp_linux_dmabuf_feedback_v1::Event::Done => state.done = true,
_ => {}
}
}
}
/// The major and minor numbers of a Linux `dev_t`, as glibc's and musl's `major()` and `minor()`
/// split it.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn major_minor(device: u64) -> (u64, u64) {
let major = ((device & 0x0000_0000_000f_ff00) >> 8) | ((device & 0xffff_f000_0000_0000) >> 32);
let minor = (device & 0x0000_0000_0000_00ff) | ((device & 0x0000_0fff_fff0_0000) >> 12);
(major, minor)
}
/// The PCI ids of the GPU behind DRM device `device` (its `cardN` or `renderDN` node), from
/// `<sys_dev_char>/MAJOR:MINOR/device/{vendor,device}`.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn pci_ids(sys_dev_char: &std::path::Path, device: u64) -> Option<(u32, u32)> {
let read_hex = |p: std::path::PathBuf| -> Option<u32> {
let s = std::fs::read_to_string(p).ok()?;
u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok()
};
let (major, minor) = major_minor(device);
let gpu = sys_dev_char.join(format!("{major}:{minor}")).join("device");
Some((read_hex(gpu.join("vendor"))?, read_hex(gpu.join("device"))?))
}
#[cfg(test)]
mod tests {
/// glibc's `makedev()`.
fn makedev(major: u64, minor: u64) -> u64 {
((major & 0x0000_0fff) << 8) | ((major & 0xffff_f000) << 32) | (minor & 0x0000_00ff) | ((minor & 0xffff_ff00) << 12)
}
#[test]
fn device_numbers_split_into_major_and_minor() {
// DRM nodes: card1 is 226:1, renderD128 is 226:128.
assert_eq!(super::major_minor(0xe201), (226, 1));
assert_eq!(super::major_minor(0xe280), (226, 128));
for (major, minor) in [(0, 0), (226, 129), (511, 1_048_575), (0xfff, 0xff), (0x1000, 0x100), (0xffff_ffff, 0xffff_ffff)] {
assert_eq!(super::major_minor(makedev(major, minor)), (major, minor));
}
}
#[cfg(target_os = "linux")]
#[test]
fn the_gpu_behind_a_device_number_comes_from_sysfs() -> std::io::Result<()> {
let dir = std::env::temp_dir().join(format!("pdfcraft-dev-char-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
// /sys/dev/char/226:128 links to the render node, whose `device` is the PCI GPU.
std::fs::create_dir_all(dir.join("226:128/device"))?;
std::fs::write(dir.join("226:128/device/vendor"), "0x10de\n")?;
std::fs::write(dir.join("226:128/device/device"), "0x25ac\n")?;
// A device that isn't a PCI GPU (a virtual one) has no ids.
std::fs::create_dir_all(dir.join("226:0/device"))?;
let render = super::pci_ids(&dir, makedev(226, 128));
let virtual_gpu = super::pci_ids(&dir, makedev(226, 0));
let missing = super::pci_ids(&dir, makedev(226, 1));
std::fs::remove_dir_all(&dir)?;
assert_eq!(render, Some((0x10de, 0x25ac)));
assert_eq!(virtual_gpu, None);
assert_eq!(missing, None);
Ok(())
}
#[test]
fn the_compositor_names_a_pci_gpu_or_nothing() {
// Whatever this session has: no compositor (CI), one that doesn't say, or a PCI GPU.
if let Some((vendor, _)) = super::compositor_gpu() {
assert_ne!(vendor, 0);
}
}
}

View File

@ -0,0 +1,70 @@
//! Who draws the window frame on Linux.
//!
//! Wayland compositors that draw frames for their clients offer `zxdg_decoration_manager_v1`
//! (KDE, Sway, Hyprland, COSMIC). GNOME doesn't: it leaves the frame to the app, and winit then
//! drew an imitation of GNOME's. Without that protocol PdfCraft turns winit's frame off and draws
//! a header bar like GNOME's own apps (`pdfcraft_ui_egui::window_controls`), with the window
//! buttons GNOME's `button-layout` names. X11 window managers always draw the frame.
//!
//! `PDFCRAFT_TITLEBAR=app` or `=system` overrides the choice.
use pdfcraft_ui_egui::window_controls::Layout;
/// The window buttons to draw in the app, or `None` when the desktop draws the frame.
pub(crate) fn app_drawn() -> Option<Layout> {
let drawn = match std::env::var("PDFCRAFT_TITLEBAR").ok().as_deref().map(str::trim) {
Some("app") => true,
Some("system") => false,
_ => compositor_leaves_frame_to_app(),
};
drawn.then(button_layout)
}
/// GNOME's button order, or minimize, maximize and close on the right when it names none.
fn button_layout() -> Layout {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
if let Some(value) = crate::text_scale::gsettings(&["get", "org.gnome.desktop.wm.preferences", "button-layout"]) {
let layout = Layout::parse(&value);
// A layout without a single button would leave no way to close the window by mouse.
if !layout.left.is_empty() || !layout.right.is_empty() {
return layout;
}
}
Layout::default()
}
/// Whether this is a Wayland session whose compositor draws no frames.
#[cfg(target_os = "linux")]
fn compositor_leaves_frame_to_app() -> bool {
use wayland_client::Connection;
use wayland_client::globals::registry_queue_init;
// No Wayland compositor: X11, whose window manager draws the frame (winit falls back to it).
let Ok(conn) = Connection::connect_to_env() else { return false };
let Ok((globals, _queue)) = registry_queue_init::<Registry>(&conn) else { return false };
let offered = globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1"));
if !offered {
log::info!("the Wayland compositor draws no window frames; PdfCraft draws its own title bar");
}
!offered
}
#[cfg(not(target_os = "linux"))]
fn compositor_leaves_frame_to_app() -> bool {
false
}
#[cfg(target_os = "linux")]
struct Registry;
#[cfg(target_os = "linux")]
impl wayland_client::Dispatch<wayland_client::protocol::wl_registry::WlRegistry, wayland_client::globals::GlobalListContents> for Registry {
fn event(
_: &mut Self,
_: &wayland_client::protocol::wl_registry::WlRegistry,
_: wayland_client::protocol::wl_registry::Event,
_: &wayland_client::globals::GlobalListContents,
_: &wayland_client::Connection,
_: &wayland_client::QueueHandle<Self>,
) {
}
}

View File

@ -4,10 +4,14 @@
//! `--create-images [images…]` stages the images in one PDF and asks for the page DPI.
//!
//! View options (applied after the files open; also the seed of the UI control channel):
//! `--page N --zoom 150 --layout continuous|two-up|single --panel comments|bookmarks|pages|fields|layers|attachments|none
//! `--page N --zoom 150 --layout single|continuous|two-up|two-page --continuous on|off --panel comments|bookmarks|pages|fields|layers|attachments|none
//! --theme light|dark|system --language auto|<code> --mode all|read|edit|convert|sign --tool <catalogue id> --left open|closed
//! --organize on --fields on --dialog properties|shortcuts|about --palette <query> --home on
//! --cover on|off --default-layout continuous|two-up|single --default-zoom fit-width|fit-page|<percent>`
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent>
//! --combine-view grid|list --combine-zoom 60..200`
//!
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
//! hands them to the PdfCraft already running, where they open as tabs (`single_instance`).
//!
//! `--control <file>` enables the UI control channel (off by default): the app listens on a random
//! loopback port and writes `{"port", "token", "pid"}` to `<file>` (owner-only permissions). The
@ -27,8 +31,18 @@ use pdfcraft_ui_egui::PdfCraftApp;
#[cfg(target_os = "macos")]
mod apple_events;
mod compositor_gpu;
mod decorations;
mod logging;
#[cfg(target_os = "windows")]
mod motion;
mod reopen;
mod single_instance;
mod text_scale;
mod updates;
#[cfg(test)]
#[path = "windows_manifest.rs"]
mod windows_manifest;
/// Freedesktop app id: the `.desktop` file name and the hicolor icon name.
const APP_ID: &str = "ai.storyteller.pdfcraft";
@ -113,6 +127,35 @@ fn path_from_arg(arg: String) -> String {
String::from_utf8(out).unwrap_or(arg)
}
/// Some programs start the PDF reader with the path unquoted (`pdfcraft.exe P:\Docs\01 Contract.PDF`),
/// so a path with spaces arrives split into several arguments (Acrobat accepts this). When an
/// argument names no file but it and the ones after it, joined with one space, do, rejoin them
/// (the longest such run). Anything else is kept as given. Runs of several spaces can't be recovered: the split has lost them.
fn rejoin_split_paths(args: Vec<String>, exists: impl Fn(&str) -> bool) -> Vec<String> {
let mut out = Vec::with_capacity(args.len());
let mut i = 0;
while let Some(first) = args.get(i) {
if exists(first) {
out.push(first.clone());
i += 1;
continue;
}
// The longest run starting here that names a file; capped so a long list stays cheap.
let end = (i + 2..=args.len().min(i + 32)).rev().find(|&j| args.get(i..j).is_some_and(|run| exists(&run.join(" "))));
match end.and_then(|j| Some((j, args.get(i..j)?.join(" ")))) {
Some((j, joined)) => {
out.push(joined);
i = j;
}
None => {
out.push(first.clone());
i += 1;
}
}
}
out
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
@ -137,15 +180,17 @@ fn main() -> eframe::Result {
let mut options: Vec<(String, String)> = Vec::new();
let mut control_file: Option<String> = None;
let mut create_images = false;
let mut new_window = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--version" => {
println!("pdfcraft {}", env!("CARGO_PKG_VERSION"));
println!("pdfcraft {}", pdfcraft_engine::build_info::version());
return Ok(());
}
"--control" => control_file = args.next(),
"--create-images" => create_images = true,
"--new-window" => new_window = true,
flag if flag.starts_with("--") => {
let value = args.next().unwrap_or_default();
options.push((flag.trim_start_matches("--").to_string(), value));
@ -153,20 +198,32 @@ fn main() -> eframe::Result {
_ => files.push(path_from_arg(a)),
}
}
let files = rejoin_split_paths(files, |p| std::path::Path::new(p).is_file());
let integrated = cfg!(target_os = "macos");
// GNOME on Wayland leaves the window frame to the app (src/decorations.rs).
let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() };
migrate_legacy_folders();
// The log file lives in the settings folder; opened after the arguments (so `--version` leaves
// no file behind) and after the PrintCraft migration (which a fresh folder would block).
// Records logged until now are written to it first.
if let (Some(logger), Some(dir)) = (logger, settings_dir()) {
match logger.attach_dir(&dir.join("logs")) {
Ok(path) => log::info!("PdfCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
Ok(path) => log::info!("PdfCraft {}, log file {}", pdfcraft_engine::build_info::version(), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
Err(e) => log::warn!("no log file: {e}"),
}
}
// Windows: a launch that only names files (an Outlook attachment, an Explorer double-click)
// hands them to the PdfCraft already running and exits (#282, #317). Any option means the
// caller wants this launch's own window.
let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images;
let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) {
single_instance::Claim::HandedOff => return Ok(()),
single_instance::Claim::Primary(server) => Some(server),
single_instance::Claim::Alone => None,
};
let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref());
let launch = Launch { files, options, control_file, create_images, integrated };
let launch = Launch { files, options, control_file, create_images, integrated, window_controls };
// Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one
// that launched us as well as later ones. Lives until the event loop returns.
#[cfg(target_os = "macos")]
@ -176,10 +233,11 @@ fn main() -> eframe::Result {
let first = if choice == RendererChoice::Gl { eframe::Renderer::Glow } else { eframe::Renderer::Wgpu };
let result = eframe::run_native(
"PdfCraft",
native_options(integrated, first),
native_options(launch.integrated, launch.window_controls.is_some(), first),
app_creator(
launch.clone(),
Rc::clone(&started),
instance.as_ref(),
#[cfg(target_os = "macos")]
&apple_events,
),
@ -193,10 +251,11 @@ fn main() -> eframe::Result {
log::error!("the GPU renderer (wgpu) didn't start: {e}. Starting with OpenGL instead; set PDFCRAFT_RENDERER=gl to skip wgpu.");
eframe::run_native(
"PdfCraft",
native_options(integrated, eframe::Renderer::Glow),
native_options(launch.integrated, launch.window_controls.is_some(), eframe::Renderer::Glow),
app_creator(
launch,
started,
instance.as_ref(),
#[cfg(target_os = "macos")]
&apple_events,
),
@ -232,7 +291,7 @@ fn retry_with_gl(choice: RendererChoice, app_started: bool) -> bool {
}
/// The window and renderer settings for one run.
fn native_options(integrated: bool, renderer: eframe::Renderer) -> eframe::NativeOptions {
fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer) -> eframe::NativeOptions {
let mut viewport = egui::ViewportBuilder::default()
.with_title("PdfCraft")
.with_inner_size([1440.0, 920.0])
@ -248,6 +307,10 @@ fn native_options(integrated: bool, renderer: eframe::Renderer) -> eframe::Nativ
if integrated {
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
}
if app_frame {
// The tab strip is the title bar (pdfcraft_ui_egui::window_controls).
viewport = viewport.with_decorations(false);
}
let persistence_path = settings_dir().map(|d| d.join("app.ron"));
// Set explicitly, so the renderer never depends on which one eframe defaults to.
let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() };
@ -265,27 +328,48 @@ struct Launch {
control_file: Option<String>,
create_images: bool,
integrated: bool,
/// The window buttons the app draws, when the desktop leaves the frame to it.
window_controls: Option<pdfcraft_ui_egui::window_controls::Layout>,
}
fn app_creator<'a>(
launch: Launch,
started: Rc<Cell<bool>>,
instance: Option<&'a single_instance::Server>,
#[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents,
) -> eframe::AppCreator<'a> {
let Launch { files, options, control_file, create_images, integrated } = launch;
let Launch { files, options, control_file, create_images, integrated, window_controls } = launch;
Box::new(move |cc| {
started.set(true);
let mut app = PdfCraftApp::new();
// First start (or settings from before Units existed): the region's unit.
app.units = pdfcraft_ui_egui::units::Unit::system();
#[cfg(target_os = "linux")]
app.enable_desktop_theme();
// The system's reopen-windows choice (macOS) starts the preference; a saved choice replaces it.
app.reopen_last_session = reopen::system_default();
if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) {
app.restore(&json);
}
app.integrated_titlebar = integrated;
app.window_controls = window_controls;
app.system_text_scale = text_scale::system();
// Windows' Animation effects setting, which Preferences ▸ Animation effects follows unless
// it is set to On or Off.
#[cfg(target_os = "windows")]
{
app.motion.source = Some(motion::source());
}
app.update_source = Some(std::sync::Arc::new(updates::latest_release));
app.printer_source = Some(pdfcraft_ui_egui::system_printers());
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
#[cfg(target_os = "macos")]
{
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
}
if let Some(server) = instance {
app.os_events = Some(server.connect(&cc.egui_ctx));
}
if let Some(file) = &control_file {
let client = app.attach_control(&cc.egui_ctx);
open_control_channel(file, pdfcraft_ui_egui::control::serve(client))?;
@ -295,7 +379,17 @@ fn app_creator<'a>(
app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir));
}
if let Some(state) = &cc.wgpu_render_state {
notify_software_renderer(&mut app, state.adapter.get_info().device_type);
let adapter = state.adapter.get_info();
let max_texture_dimension_2d = state.device.limits().max_texture_dimension_2d;
log::info!(
"renderer: wgpu; adapter: {} ({:?}, {:?}); max_texture_dimension_2d: {max_texture_dimension_2d}",
adapter.name,
adapter.device_type,
adapter.backend
);
notify_software_renderer(&mut app, adapter.device_type);
} else {
log::info!("renderer: OpenGL (glow)");
}
// A portable marker whose data folder can't be written (#157): say where settings went.
if let Some(w) = &pdfcraft_ui_egui::portable::current().unwritable {
@ -420,12 +514,21 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
/// desktop with a monitor on each GPU, issue #445) the discrete one wins, as the integrated one
/// may fail to present there. Where the system doesn't say which GPU drives a display (macOS,
/// containers, remote sessions) the power preference alone decides.
/// - Under Wayland, the GPU the compositor draws with comes before all of that: the compositor
/// says which one it is ([`compositor_gpu::compositor_gpu`]), and frames from another GPU may
/// never reach the screen, even from the GPU behind the built-in panel when the compositor runs
/// on the discrete one.
/// - On Windows, use Direct3D 12, falling back to OpenGL, and never load Vulkan drivers unless
/// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver
/// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before
/// its window appeared. D3D12 is the native, best-supported backend there.
fn configure_gpu(native: &mut eframe::NativeOptions) {
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut native.wgpu_options.wgpu_setup else { return };
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a
// compute shader when the device is created, one that Metal can't compile on an Intel Iris
// Pro: the device is lost and the window falls back to OpenGL. `WGPU_VALIDATION_INDIRECT_CALL=1`
// turns the check back on.
setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
// egui-wgpu requests only 8192 pixels even when the adapter can render larger surfaces.
// A restored 3440-point window at 250% DPI requests 8600 pixels and otherwise panics in
// Surface::configure before the app starts. Keep the renderer's other device requirements,
@ -439,7 +542,8 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
if std::env::var_os("WGPU_POWER_PREF").is_none() {
setup.power_preference = eframe::wgpu::PowerPreference::LowPower;
let displays = display_gpus();
if !displays.is_empty() {
let compositor = compositor_gpu::compositor_gpu();
if !displays.is_empty() || compositor.is_some() {
setup.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, surface| {
let usable: Vec<&eframe::wgpu::Adapter> = adapters.iter().filter(|a| surface.is_none_or(|s| a.is_surface_supported(s))).collect();
let infos: Vec<(u32, u32, eframe::wgpu::DeviceType)> = usable
@ -449,7 +553,7 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
(info.vendor, info.device, info.device_type)
})
.collect();
let picked = pick_adapter(&infos, &displays).and_then(|i| usable.get(i)).map(|a| (*a).clone());
let picked = pick_adapter(&infos, &displays, compositor).and_then(|i| usable.get(i)).map(|a| (*a).clone());
match &picked {
// Which GPU draws is the first question when a window stays black (#8, #378, #445).
Some(a) => {
@ -582,12 +686,12 @@ fn linux_display_gpus(drm: &std::path::Path) -> Vec<DisplayGpu> {
gpus
}
/// Index of the adapter to draw with: one that drives a display (by PCI ids) first. Among several
/// of those, the one driving the primary display (a hybrid laptop's panel, #8; the primary display
/// on Windows, #378), or else, with no primary display known (a Linux desktop with a monitor on
/// each GPU, #445), the discrete one. Otherwise the most frugal kind: integrated, discrete, other,
/// virtual, software.
fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[DisplayGpu]) -> Option<usize> {
/// Index of the adapter to draw with: the GPU the Wayland compositor draws with (`compositor`, by
/// PCI ids) first, then one that drives a display. Among several of those, the one driving the
/// primary display (a hybrid laptop's panel, #8; the primary display on Windows, #378), or else,
/// with no primary display known (a Linux desktop with a monitor on each GPU, #445), the discrete
/// one. Otherwise the most frugal kind: integrated, discrete, other, virtual, software.
fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[DisplayGpu], compositor: Option<(u32, u32)>) -> Option<usize> {
use eframe::wgpu::DeviceType;
let display = |pci: (u32, u32)| displays.iter().find(|g| g.pci == pci);
let multi_gpu_desktop = displays.len() > 1 && !displays.iter().any(|g| g.primary);
@ -608,7 +712,7 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D
DeviceType::VirtualGpu => 4,
DeviceType::Cpu => 5,
};
(!drives_display, !drives_primary, rank)
(compositor != Some((*vendor, *device)), !drives_display, !drives_primary, rank)
})
.map(|(i, _)| i)
}
@ -621,6 +725,19 @@ mod tests {
Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() })
}
/// Tests that open a real GPU device take this first, so only one of them is inside the
/// graphics libraries at a time. The test threads otherwise run them together, and on
/// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs:
/// the first device was still being set up when the second test began its own. The UI
/// tests take turns the same way (`crates/ui-egui/tests/view.rs`). Hold it for the whole
/// test (declared before the instance, so it's released after the device is dropped).
static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn gpu() -> std::sync::MutexGuard<'static, ()> {
// A test that panicked while holding it leaves nothing to clean up.
GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn a_control_channel_that_cant_be_published_stops_the_app() {
let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id()));
@ -655,6 +772,24 @@ mod tests {
assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf");
}
#[test]
fn unquoted_paths_with_spaces_are_rejoined() {
let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"];
let exists = |p: &str| have.contains(&p);
let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
// The case an office program produced: one path, split at its space.
assert_eq!(
super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists),
args(&["P:\\Archive\\00042A26 Contract.PDF"])
);
// The longest run wins; files that exist on their own are left alone.
assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"]));
assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"]));
// Nothing to join: kept as given, so the missing file is still reported by name.
assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"]));
assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty());
}
#[test]
fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() {
assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf");
@ -680,8 +815,15 @@ mod tests {
assert!(!retry_with_gl(RendererChoice::Wgpu, false));
assert!(!retry_with_gl(RendererChoice::Gl, false));
// Each run states its renderer rather than relying on eframe's default.
assert_eq!(native_options(false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
assert_eq!(native_options(false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
}
#[test]
fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() {
use super::native_options;
assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None);
assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false));
}
#[test]
@ -709,6 +851,10 @@ mod tests {
if std::env::var_os("WGPU_POWER_PREF").is_none() {
assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower);
}
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
let flags = setup.instance_descriptor.flags;
assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}");
}
if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() {
let backends = setup.instance_descriptor.backends;
assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}");
@ -827,7 +973,8 @@ mod tests {
// and an error wgpu raises before anything reaches the driver: a texture one pixel wider
// than the device allows.
use eframe::wgpu;
let native = super::native_options(false, eframe::Renderer::Wgpu);
let _gpu = gpu();
let native = super::native_options(false, false, eframe::Renderer::Wgpu);
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else {
panic!("default setup creates its own instance")
};
@ -890,6 +1037,37 @@ mod tests {
}
}
#[test]
fn pipeline_creation_error_is_returned_and_device_remains_usable() {
use eframe::{egui_wgpu, wgpu};
let _gpu = gpu();
let mut native = super::native_options(false, false, eframe::Renderer::Wgpu);
let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else {
assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run");
eprintln!("skipping pipeline regression: no GPU adapter");
return;
};
let (device, queue) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter)))
.expect("the selected adapter must provide a test device");
native.wgpu_options.wgpu_setup =
egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue });
// Three samples is invalid on every backend, so this reproduces a pipeline error
// without depending on a particular driver. The unpatched constructor panics.
for samples in [3, 1, 3, 1] {
let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() };
let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options));
if samples == 3 {
let error = result.err().expect("invalid pipeline must return an error");
assert!(error.to_string().contains("egui_pipeline"), "{error}");
} else {
assert!(result.is_ok(), "the device must remain usable: {:?}", result.err());
}
}
}
#[test]
fn winit_carries_the_windows_11_monitor_scale_fix() {
// Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another
@ -929,17 +1107,17 @@ mod tests {
#[test]
fn pick_adapter_prefers_the_gpu_driving_the_monitors() {
// A desktop whose monitors are all on the discrete GPU: the integrated one shows black.
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)]), Some(0));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0));
// Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed
// the display driver. Windows also says which display is the primary one.
assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)]), Some(0));
assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0));
}
#[test]
fn pick_adapter_prefers_the_gpu_driving_the_primary_display() {
// A Windows desktop with a monitor on each GPU: the window opens on the primary display.
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)]), Some(0));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
}
#[test]
@ -971,10 +1149,10 @@ mod tests {
#[test]
fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() {
// Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one.
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1));
}
#[test]
@ -983,17 +1161,32 @@ mod tests {
use eframe::wgpu::DeviceType;
let desktop =
[(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)];
assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)]), Some(1));
assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)]), Some(1));
assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1));
assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1));
// A GPU driving no display still loses to one that does.
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1));
}
#[test]
fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() {
// A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with
// `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the
// compositor shows nothing it draws, and reports no error either.
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0));
// The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8).
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1));
// No GPU drives a display as far as sysfs says (a container), but the compositor knows.
assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0));
// A compositor GPU no adapter matches (a virtual one) leaves the displays in charge.
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1));
}
#[test]
fn pick_adapter_falls_back_to_low_power_without_a_match() {
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))]), Some(1));
assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)]), None);
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1));
assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None);
}
#[test]
@ -1114,4 +1307,13 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
#[test]
fn the_desktop_entry_names_the_window_class() {
let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop");
let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
assert_eq!(class, Some(super::APP_ID));
}
}

View File

@ -0,0 +1,75 @@
//! The system's "no animations" setting, which Preferences ▸ Animation effects follows unless it is
//! set to On or Off (`pdfcraft_ui_egui::motion`).
//!
//! Windows: Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (in the Control Panel,
//! "Show animations in Windows"), read through WinRT's `UISettings.AnimationsEnabled`. Other
//! systems aren't asked yet: there the window animates as before.
use pdfcraft_ui_egui::motion::MotionSource;
/// Asks the system each time, and logs the answer when it changes.
pub fn source() -> MotionSource {
let last = std::cell::Cell::new(None);
Box::new(move || {
let reduced = reduced();
if reduced.is_some() && last.replace(reduced) != reduced {
log::info!("system animation effects: {}", if reduced == Some(true) { "off" } else { "on" });
}
reduced
})
}
/// Whether the system asks for no animations; `None` when it can't be asked.
pub fn reduced() -> Option<bool> {
match animations_enabled() {
Ok(enabled) => Some(!enabled),
Err(e) => {
log::debug!("system animation effects: {e}");
None
}
}
}
fn animations_enabled() -> windows::core::Result<bool> {
windows::UI::ViewManagement::UISettings::new()?.AnimationsEnabled()
}
#[cfg(test)]
mod tests {
/// The user's saved Animation effects choice, or `None` when `reg` can't read it. Windows
/// keeps its on/off system parameters as the bits of `UserPreferencesMask`: bit n is the
/// parameter `SPI_GET…` 0x1000 + 2n, and client-area animation is 0x1042, so bit 33.
fn saved_choice() -> Option<bool> {
let out = std::process::Command::new("reg").args(["query", r"HKCU\Control Panel\Desktop", "/v", "UserPreferencesMask"]).output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let hex = text.split_whitespace().skip_while(|word| *word != "REG_BINARY").nth(1)?;
let byte = u8::from_str_radix(hex.get(8..10)?, 16).ok()?;
Some(byte & 0b10 != 0)
}
#[test]
fn the_answer_is_the_systems_animation_effects_setting() {
let answer = super::reduced();
// The first answer is logged and later ones only when they change; the answers don't.
let source = super::source();
assert_eq!((source(), source()), (answer, answer));
match super::animations_enabled() {
// With Animation effects turned off, the answer must be "reduced". Only this way
// round: the session's own value may be off for other reasons (a policy, a remote
// session) while the saved choice is on.
Ok(enabled) => {
assert_eq!(answer, Some(!enabled));
if saved_choice() == Some(false) {
assert!(!enabled, "Animation effects are off in Settings, but the answer was that animations are on");
}
}
Err(e) => {
eprintln!("UISettings isn't available here ({e}); not compared with the saved choice");
assert_eq!(answer, None);
}
}
}
}

View File

@ -0,0 +1,72 @@
//! The system's choice for Preferences ▸ "Reopen the files that were open when PdfCraft last closed".
//!
//! On macOS, System Settings ▸ Desktop & Dock ▸ Windows ▸ "Close windows when quitting an
//! application" is on by default; turning it off stores `NSQuitAlwaysKeepsWindows = 1`, and apps
//! then reopen their windows on the next launch. A user who turned it off expects PdfCraft to
//! bring its documents back too (#871), so that setting is the preference's starting value. It
//! is read once at launch through `defaults`; the user's own choice in Preferences, once saved,
//! wins over it. Other systems have no such setting, and the preference starts off there.
/// How long `defaults` may take before launch goes on without it.
#[cfg(target_os = "macos")]
const TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
/// Whether reopening the last session starts on, before the saved preferences are applied.
pub fn system_default() -> bool {
#[cfg(target_os = "macos")]
if let Some(output) = defaults(&["read", "-g", "NSQuitAlwaysKeepsWindows"]) {
return keeps_windows(&output);
}
false
}
/// `defaults read` output for a boolean (`1`, `0`, `true`, `false`): true only when it says so.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn keeps_windows(output: &str) -> bool {
matches!(output.trim(), "1" | "true" | "yes" | "YES")
}
/// Run `defaults` and return what it printed, or `None` if it fails or hangs. Fails when the key
/// was never set, which means the system default: close windows when quitting.
#[cfg(target_os = "macos")]
fn defaults(args: &[&str]) -> Option<String> {
use std::io::Read;
use std::process::{Command, Stdio};
// The absolute path keeps a `defaults` earlier on PATH from running.
let mut child = Command::new("/usr/bin/defaults").args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => break,
Ok(None) if start.elapsed() < TIMEOUT => std::thread::sleep(std::time::Duration::from_millis(5)),
_ => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
let mut out = String::new();
// A boolean fits in far less; a misbehaving `defaults` can't make launch read without end.
child.stdout.take()?.take(256).read_to_string(&mut out).ok()?;
Some(out)
}
#[cfg(test)]
mod tests {
#[test]
fn only_a_set_flag_keeps_windows() {
for on in ["1\n", "1", "true", "YES"] {
assert!(super::keeps_windows(on), "{on:?}");
}
for off in ["0\n", "0", "false", "NO", "", "garbage"] {
assert!(!super::keeps_windows(off), "{off:?}");
}
}
#[test]
fn reading_the_system_default_never_fails() {
// Whatever this machine is set to, the read returns a value within the timeout.
let _ = super::system_default();
}
}

View File

@ -0,0 +1,402 @@
//! One PdfCraft per Windows sign-in: a launch that only names files hands them to the PdfCraft
//! that is already running, where they open as tabs, and exits (#282, #317).
//!
//! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe "<file>"`, so
//! until now every file got its own process and window. The first PdfCraft listens on a Unix
//! domain socket (Windows 10 1803 and later) in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of
//! a portable copy. The socket is named after the Windows session (`instance-Console.sock`), so
//! two sign-ins of the same user never open files in each other's windows, and it lives in the
//! user's own profile, so other accounts can't create, replace or reach it: nobody else can
//! intercept a launch or open files in this user's window. The exchange is one line each way, a
//! JSON request naming absolute paths and `ok`, and the running app opens the paths exactly as
//! File ▸ Open would.
//!
//! Every failure falls back to the old behaviour: a launch that can't hand its files over within
//! a few seconds opens its own window. `--new-window`, any other option, or no files at all also
//! starts a window of its own, as before. Other platforms keep their current behaviour (macOS
//! already routes Finder opens to the running app through Apple events).
//!
//! The socket comes from `uds_windows`, already in the lockfile through zbus: `std` has no Unix
//! sockets on Windows, and calling Winsock directly would need `unsafe`, which the workspace
//! forbids.
use std::sync::{Arc, Mutex, PoisonError};
#[cfg(windows)]
use std::time::Duration;
use pdfcraft_ui_egui::{OsEvent, OsEventsFn};
/// Longest request read: one line of JSON, far longer than any real list of paths.
#[cfg_attr(not(windows), allow(dead_code))]
const MAX_REQUEST: u64 = 1 << 20;
/// How long either side waits for the other before giving up.
#[cfg(windows)]
const TIMEOUT: Duration = Duration::from_secs(5);
/// What this launch should do.
#[cfg_attr(not(windows), allow(dead_code))]
pub enum Claim {
/// The running PdfCraft took the files: exit without a window.
HandedOff,
/// This is the first PdfCraft: open a window and accept later launches' files.
Primary(Server),
/// Open a window of its own and accept nothing (the behaviour before #282).
Alone,
}
/// Files that later launches handed over, and how to wake the UI for them.
#[derive(Default)]
struct Inbox {
paths: Vec<String>,
wake: Option<Box<dyn Fn() + Send>>,
}
/// The first PdfCraft's end of the socket. A background thread accepts launches until the
/// process exits; the UI picks their files up through [`Server::connect`].
pub struct Server {
inbox: Arc<Mutex<Inbox>>,
}
impl Server {
#[cfg_attr(not(windows), allow(dead_code))]
fn new() -> Self {
Self { inbox: Arc::default() }
}
/// Queue files from another launch and wake the UI.
#[cfg_attr(not(windows), allow(dead_code))]
fn deliver(&self, paths: Vec<String>) {
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
inbox.paths.extend(paths);
if let Some(wake) = &inbox.wake {
wake();
}
}
/// The queue the UI drains every frame (`PdfCraftApp::os_events`). Files that arrived before
/// the window existed are in the first batch. A batch also brings the window forward, as the
/// new window it replaces would have been.
pub fn connect(&self, ctx: &egui::Context) -> OsEventsFn {
let wake = ctx.clone();
self.inbox.lock().unwrap_or_else(PoisonError::into_inner).wake = Some(Box::new(move || wake.request_repaint()));
let inbox = Arc::clone(&self.inbox);
let ctx = ctx.clone();
Box::new(move || {
let paths = std::mem::take(&mut inbox.lock().unwrap_or_else(PoisonError::into_inner).paths);
if paths.is_empty() {
return Vec::new();
}
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
vec![OsEvent::Open(paths)]
})
}
}
/// The request a launch sends: `{"open":[…]}` and a newline.
#[cfg_attr(not(windows), allow(dead_code))]
pub fn request(paths: &[String]) -> String {
let mut line = serde_json::json!({ "open": paths }).to_string();
line.push('\n');
line
}
/// The paths a request names, or `None` when it isn't a request: anything but one JSON object
/// whose `open` is a non-empty list of non-empty strings.
#[cfg_attr(not(windows), allow(dead_code))]
pub fn parse_request(line: &str) -> Option<Vec<String>> {
let value: serde_json::Value = serde_json::from_str(line.trim_end()).ok()?;
let open = value.get("open")?.as_array()?;
let paths = open.iter().map(|p| p.as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::<Option<Vec<String>>>()?;
(!paths.is_empty()).then_some(paths)
}
/// File arguments made absolute against this launch's working folder, which the running app
/// doesn't share. A path that can't be made absolute is sent as it is.
pub fn absolute(paths: &[String]) -> Vec<String> {
paths.iter().map(|p| std::path::absolute(p).map_or_else(|_| p.clone(), |a| a.to_string_lossy().into_owned())).collect()
}
/// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false
/// when the launch asked for anything besides opening files, so it gets its own window.
pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
#[cfg(windows)]
{
match socket_path() {
Some(path) => claim_at(&path, files, may_hand_off),
None => Claim::Alone,
}
}
#[cfg(not(windows))]
{
let _ = (files, may_hand_off);
Claim::Alone
}
}
/// The socket in `%LOCALAPPDATA%\PdfCraft` (beside the crash-recovery folder); none when
/// `%LOCALAPPDATA%` is unset.
#[cfg(windows)]
fn socket_path() -> Option<std::path::PathBuf> {
let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref());
// %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this
// user's file paths or send it files to open. A portable copy uses it too (#889), under its
// own name so it never hands files to an installed copy (which keeps its settings elsewhere).
// Never the portable folder itself: it may sit where other accounts can write (`C:\Tools`).
let name = if pdfcraft_ui_egui::portable::data_dir().is_some() { format!("portable-{name}") } else { name };
std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(|d| std::path::PathBuf::from(d).join("PdfCraft").join(name))
}
/// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The
/// same user signed in twice (at the console and over Remote Desktop) gets one socket per session,
/// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept.
#[cfg_attr(not(windows), allow(dead_code))]
fn socket_name(session: Option<&str>) -> String {
let session: String = session.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
if session.is_empty() { "instance.sock".to_string() } else { format!("instance-{session}.sock") }
}
/// Held while a launch finds out whether it is the first, so launches that start together (several
/// files selected in Explorer and opened at once) take turns: the first one is listening before
/// the next one looks. Without it a launch could find the socket bound but not yet listening,
/// fail to clear it ("Access is denied") and open a window of its own. The lock is on a file
/// beside the socket, which is never deleted; Windows releases it when the process ends, however
/// it ends. Best effort: without the lock the launch goes on as before.
#[cfg(windows)]
fn election(socket: &std::path::Path) -> Option<std::fs::File> {
if let Some(dir) = socket.parent() {
let _ = std::fs::create_dir_all(dir);
}
let file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(socket.with_extension("lock")).ok()?;
let deadline = std::time::Instant::now() + TIMEOUT;
loop {
match file.try_lock() {
Ok(()) => return Some(file),
Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)),
Err(e) => {
log::info!("single instance: no election lock ({e})");
return None;
}
}
}
}
/// [`claim`] with the socket at `path`.
#[cfg(windows)]
fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Claim {
use uds_windows::{UnixListener, UnixStream};
// Released when this function returns: by then this launch listens, or has handed over.
let _election = election(path);
// Two launches can start together: the one that loses the race to bind hands over to the
// one that won. A third round is for a socket that vanishes in between.
for _ in 0..3 {
match UnixStream::connect(path) {
Ok(stream) => {
if !may_hand_off {
return Claim::Alone;
}
return match hand_off(&stream, files) {
Ok(()) => Claim::HandedOff,
Err(e) => {
log::warn!("couldn't hand the files to the running PdfCraft ({e}); opening a new window");
Claim::Alone
}
};
}
// Nobody is listening: no socket yet, or one left by a PdfCraft that didn't exit
// cleanly (connecting to it is refused). Clear it and become the first. Removed
// without asking `path.exists()` first: on Windows that follows the socket's reparse
// point, which can fail, and a leftover socket would then block every later start.
Err(_) => match std::fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
// Another launch has bound the socket and is about to listen: try it again.
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
std::thread::sleep(Duration::from_millis(50));
continue;
}
Err(e) => {
log::info!("single instance off: can't clear {} ({e})", path.display());
return Claim::Alone;
}
},
}
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
match UnixListener::bind(path) {
Ok(listener) => return serve(listener),
// Another launch bound it first: hand over to that one on the next round.
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => continue,
// Older Windows, a path too long for a socket address, a file system without socket
// support (some network or removable drives): this launch works as before.
Err(e) => {
log::info!("single instance off: can't listen at {} ({e})", path.display());
return Claim::Alone;
}
}
}
Claim::Alone
}
/// Send `files` over `stream` and wait for the running app's `ok`.
#[cfg(windows)]
fn hand_off(stream: &uds_windows::UnixStream, files: &[String]) -> std::io::Result<()> {
use std::io::{BufRead, Read, Write};
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
let mut writer = stream;
writer.write_all(request(files).as_bytes())?;
let mut reply = String::new();
std::io::BufReader::new(stream.take(16)).read_line(&mut reply)?;
if reply == "ok\n" { Ok(()) } else { Err(std::io::Error::other("the running PdfCraft didn't confirm")) }
}
/// Accept launches on a background thread for as long as the process runs.
#[cfg(windows)]
fn serve(listener: uds_windows::UnixListener) -> Claim {
let server = Server::new();
let inbox = Server { inbox: Arc::clone(&server.inbox) };
let started = std::thread::Builder::new().name("single-instance".into()).spawn(move || {
for stream in listener.incoming() {
let result = stream.and_then(|s| receive(&s, &inbox));
if let Err(e) = result {
log::warn!("single instance: a launch's request failed ({e})");
// Don't spin if accepting keeps failing.
std::thread::sleep(Duration::from_millis(100));
}
}
});
match started {
Ok(_) => Claim::Primary(server),
Err(e) => {
log::warn!("single instance off: no listener thread ({e})");
Claim::Alone
}
}
}
/// Read one launch's request, queue its files and confirm.
#[cfg(windows)]
fn receive(stream: &uds_windows::UnixStream, server: &Server) -> std::io::Result<()> {
use std::io::{BufRead, Read, Write};
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
let mut line = String::new();
std::io::BufReader::new(stream.take(MAX_REQUEST)).read_line(&mut line)?;
// A launch that wanted its own window only checked that this one is running.
if line.is_empty() {
return Ok(());
}
let paths = parse_request(&line).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "not an open request"))?;
server.deliver(paths);
let mut writer = stream;
writer.write_all(b"ok\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_request_names_the_paths_and_round_trips() {
let paths = vec![r"C:\Users\me\Documents\Quote.pdf".to_string(), r"D:\Scans\Plan „A“ 1.pdf".to_string()];
let line = request(&paths);
assert!(line.ends_with('\n') && !line.trim_end().contains('\n'), "one line");
assert_eq!(parse_request(&line), Some(paths));
}
#[test]
fn anything_but_a_list_of_paths_is_refused() {
for line in ["", "ok\n", "{}", r#"{"open":[]}"#, r#"{"open":[""]}"#, r#"{"open":["a.pdf",3]}"#, r#"{"open":"a.pdf"}"#, "[\"a.pdf\"]"] {
assert_eq!(parse_request(line), None, "{line:?}");
}
}
#[test]
fn relative_paths_are_made_absolute_against_this_launch() {
let out = absolute(&["Quote.pdf".to_string()]);
assert_eq!(out, vec![std::env::current_dir().unwrap().join("Quote.pdf").to_string_lossy().into_owned()]);
let already = std::env::current_dir().unwrap().join("x.pdf").to_string_lossy().into_owned();
assert_eq!(absolute(std::slice::from_ref(&already)), vec![already]);
}
#[test]
fn each_windows_session_gets_its_own_socket_name() {
assert_eq!(socket_name(Some("Console")), "instance-Console.sock");
assert_eq!(socket_name(Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
assert_ne!(socket_name(Some("RDP-Tcp#3")), socket_name(Some("RDP-Tcp#4")));
assert_eq!(socket_name(Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
assert_eq!(socket_name(None), "instance.sock");
assert_eq!(socket_name(Some("")), "instance.sock");
}
#[test]
fn delivered_files_reach_the_ui_once_wake_it_and_bring_the_window_forward() {
let server = Server::new();
// Before the window exists: queued, nothing to wake yet.
server.deliver(vec!["a.pdf".into()]);
let ctx = egui::Context::default();
let mut poll = server.connect(&ctx);
server.deliver(vec!["b.pdf".into()]);
let events = poll();
assert!(matches!(events.as_slice(), [OsEvent::Open(p)] if p == &["a.pdf", "b.pdf"]));
assert!(poll().is_empty(), "each file is delivered once");
}
#[cfg(windows)]
fn socket(test: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir.join(socket_name(None))
}
#[cfg(windows)]
#[test]
fn a_second_launch_hands_its_files_to_the_first() {
let path = socket("hand-off");
let Claim::Primary(first) = claim_at(&path, &[], true) else { panic!("the first launch listens") };
let mut poll = first.connect(&egui::Context::default());
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::HandedOff));
assert!(matches!(poll().as_slice(), [OsEvent::Open(p)] if p == &["C:\\a.pdf"]));
// A launch with options keeps its own window and doesn't take over the socket.
assert!(matches!(claim_at(&path, &["C:\\b.pdf".into()], false), Claim::Alone));
assert!(poll().is_empty());
}
#[cfg(windows)]
#[test]
fn launches_started_together_share_one_window() {
// Several files selected in Explorer and opened with Enter: one process per file, all at once.
let path = socket("together");
let barrier = Arc::new(std::sync::Barrier::new(8));
let launches: Vec<_> = (0..8)
.map(|i| {
let (path, barrier) = (path.clone(), Arc::clone(&barrier));
std::thread::spawn(move || {
barrier.wait();
claim_at(&path, &[format!("C:\\{i}.pdf")], true)
})
})
.collect();
// The first launch's listener lives until every other launch has handed over.
let claims: Vec<Claim> = launches.into_iter().map(|t| t.join().unwrap()).collect();
assert_eq!(claims.iter().filter(|c| matches!(c, Claim::Primary(_))).count(), 1, "one window");
assert_eq!(claims.iter().filter(|c| matches!(c, Claim::HandedOff)).count(), 7, "the rest hand over");
}
#[cfg(windows)]
#[test]
fn a_socket_left_by_a_crashed_instance_is_replaced() {
let path = socket("stale");
// Bind and drop: the file stays, nobody listens.
drop(uds_windows::UnixListener::bind({
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
&path
}));
// `symlink_metadata`, not `exists`, which follows the socket's reparse point and can fail.
assert!(std::fs::symlink_metadata(&path).is_ok(), "the socket file stays behind");
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::Primary(_)));
}
}

View File

@ -0,0 +1,68 @@
//! The desktop's text scaling, for Preferences ▸ Interface size ▸ Auto.
//!
//! On Linux and the BSDs, GNOME (and desktops sharing its settings schema) keep it in
//! `org.gnome.desktop.interface text-scaling-factor` (Settings ▸ Accessibility ▸ Large Text sets
//! 1.25). It is read once at launch through `gsettings`, which is absent or fails elsewhere, and
//! anything but a sensible number counts as no scaling. Other systems scale the whole display
//! instead, which already reaches egui as `pixels_per_point`.
/// How long `gsettings` may take before launch goes on without it.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
const TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
/// The text scaling factor, or 1.0.
pub fn system() -> f32 {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
if let Some(output) = gsettings(&["get", "org.gnome.desktop.interface", "text-scaling-factor"]) {
return parse(&output);
}
1.0
}
/// `gsettings` output (`1.25`) as a factor; 1.0 when it isn't a usable one.
#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd")), allow(dead_code))]
fn parse(output: &str) -> f32 {
output.trim().parse::<f64>().ok().and_then(pdfcraft_ui_egui::ui_scale::valid).unwrap_or(1.0)
}
/// Run `gsettings` and return what it printed, or `None` if it is missing, fails or hangs.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
pub(crate) fn gsettings(args: &[&str]) -> Option<String> {
use std::io::Read;
use std::process::{Command, Stdio};
let mut child = Command::new("gsettings").args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => break,
Ok(None) if start.elapsed() < TIMEOUT => std::thread::sleep(std::time::Duration::from_millis(5)),
_ => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
let mut out = String::new();
// A number fits in far less; a misbehaving `gsettings` can't make launch read without end.
child.stdout.take()?.take(256).read_to_string(&mut out).ok()?;
Some(out)
}
#[cfg(test)]
mod tests {
#[test]
fn gsettings_output_parses_or_means_no_scaling() {
assert_eq!(super::parse("1.25\n"), 1.25);
assert_eq!(super::parse("1.0"), 1.0);
for bad in ["", "nan", "inf", "-2", "0", "uint32 0", "100"] {
assert_eq!(super::parse(bad), 1.0, "{bad}");
}
}
#[test]
fn reading_the_system_scale_never_fails() {
let f = super::system();
assert!(f.is_finite() && (pdfcraft_ui_egui::ui_scale::MIN..=pdfcraft_ui_egui::ui_scale::MAX).contains(&f), "{f}");
}
}

View File

@ -18,8 +18,21 @@ pub fn latest_release() -> Result<Release, String> {
.get(LATEST)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", concat!("PdfCraft/", env!("CARGO_PKG_VERSION")))
// Read error statuses ourselves: a 403 from GitHub usually means its anonymous
// rate limit ran out, and the retry time rides along in a response header.
.config()
.http_status_as_error(false)
.build()
.call()
.map_err(|e| format!("couldn't reach GitHub ({e})"))?;
let status = response.status().as_u16();
if status == 403 {
let reset = response.headers().get("x-ratelimit-reset").and_then(|v| v.to_str().ok());
return Err(rate_limited(reset, now_unix()));
}
if !(200..300).contains(&status) {
return Err(format!("couldn't reach GitHub (http status: {status})"));
}
let body = response.body_mut().with_config().limit(1 << 20).read_to_string().map_err(|e| format!("unreadable answer ({e})"))?;
parse(&body)
}
@ -34,6 +47,28 @@ fn os_roots() -> Result<ureq::tls::RootCerts, String> {
Ok(ureq::tls::RootCerts::new_with_certs(&certs))
}
/// The current Unix time in seconds (0 when the clock gives nothing usable).
fn now_unix() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
/// What to tell the user when GitHub answers 403: its anonymous quota (60 checks an hour,
/// shared by everyone behind the same address) ran out. `reset` is the `x-ratelimit-reset`
/// header, the Unix time the quota refills; `now` is the current Unix time. Anything
/// surprising (no header, not a number, already past) falls back to asking for patience.
fn rate_limited(reset: Option<&str>, now: u64) -> String {
const FALLBACK: &str = "GitHub is limiting update checks for now (shared hourly quota); try again later";
let wait = reset.and_then(|r| r.trim().parse::<u64>().ok()).and_then(|reset| reset.checked_sub(now)).filter(|&wait| wait > 0);
match wait {
Some(wait) => {
let minutes = wait.div_ceil(60).max(1);
let plural = if minutes == 1 { "" } else { "s" };
format!("GitHub is limiting update checks for now (shared hourly quota); try again in about {minutes} minute{plural}")
}
None => FALLBACK.into(),
}
}
fn parse(body: &str) -> Result<Release, String> {
let v: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("unreadable answer ({e})"))?;
let version = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string();
@ -61,6 +96,26 @@ mod tests {
assert!(parse("<html>").is_err());
}
#[test]
fn rate_limit_messages_name_the_wait() {
let limited = "GitHub is limiting update checks for now (shared hourly quota); try again in about 20 minutes";
assert_eq!(rate_limited(Some("1800001200"), 1800000000), limited);
// Rounds up: 61 seconds out reads as 2 minutes.
assert_eq!(
rate_limited(Some("1800000061"), 1800000000),
"GitHub is limiting update checks for now (shared hourly quota); try again in about 2 minutes"
);
assert_eq!(
rate_limited(Some("1800000060"), 1800000000),
"GitHub is limiting update checks for now (shared hourly quota); try again in about 1 minute"
);
// No header, not a number, or already past: ask for patience instead of a time.
let later = "GitHub is limiting update checks for now (shared hourly quota); try again later";
for bad in [None, Some(""), Some("soon"), Some("1799999999"), Some("1800000000")] {
assert_eq!(rate_limited(bad, 1800000000), later);
}
}
/// Live: asks GitHub over TLS with the OS's roots (`cargo test -p pdfcraft -- --ignored`).
#[test]
#[ignore = "needs network access"]

View File

@ -0,0 +1,34 @@
pub(crate) const WINDOWS_MANIFEST: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
"#;
#[cfg(test)]
mod tests {
use super::WINDOWS_MANIFEST;
#[test]
fn declares_per_monitor_v2_with_per_monitor_fallback() {
assert!(
WINDOWS_MANIFEST
.contains("<dpiAwareness xmlns=\"http://schemas.microsoft.com/SMI/2016/WindowsSettings\">PerMonitorV2, PerMonitor</dpiAwareness>")
);
}
#[test]
fn legacy_declaration_is_per_monitor_aware() {
assert!(WINDOWS_MANIFEST.contains("<dpiAware xmlns=\"http://schemas.microsoft.com/SMI/2005/WindowsSettings\">true/pm</dpiAware>"));
}
#[test]
fn windows_resource_build_script_embeds_this_manifest() {
let build_script = include_str!("../build.rs");
assert!(build_script.contains("set_manifest(windows_manifest::WINDOWS_MANIFEST)"));
}
}

View File

@ -0,0 +1,20 @@
//! vendor/winit patch (8) (vendor/README.md).
#[test]
fn winit_commits_macos_text_inserted_outside_a_key_press() {
// The macOS emoji picker inserts its text with `insertText` outside any key press and without
// a composition. winit 0.30.13 reports `insertText` only as part of a key press or a
// composition, so an emoji picked into a PdfCraft text field was dropped. vendor/winit commits
// such text (rust-windowing/winit#4749, the 0.30 backport of #4748). A re-vendored copy without
// the patch would silently bring the bug back: re-apply the patch, or drop the copy once a winit
// 0.30 release has the fix.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let view = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/macos/view.rs")).unwrap();
let outside_key_press = "} else if !self.ivars().in_key_down.get()\n && self.ivars().ime_allowed.get()";
assert!(view.contains(outside_key_press), "text inserted outside a key press is dropped again");
// #4748's `selectedRange` change (for dictation) is deliberately left out: a valid selection
// turns on press-and-hold for every held key in PdfCraft's text fields (vendor/README.md).
let (_, selected_range) = view.split_once("fn selected_range(&self) -> NSRange {").expect("selectedRange");
let selected_range = selected_range.split_once("\n }\n").map_or(selected_range, |(body, _)| body);
assert!(!selected_range.contains("marked_text"), "selectedRange reports a selection");
}

View File

@ -0,0 +1,19 @@
//! vendor/winit patch (6) (vendor/README.md).
#[test]
fn winit_commits_macos_compositions_that_end_without_marked_text() {
// winit 0.30.13 treats `insertText` as an IME commit only while marked text is left. Input
// methods that end a composition with `unmarkText` first lost the committed text, and Apple
// Korean, which clears its marked text before inserting the key typed after a syllable (`한`
// then `5`), lost that key. vendor/winit commits when a composition was started
// (`pending_commit`, rust-windowing/winit#4650, the 0.30 backport of #4651 on winit master), and
// a commit outside a key press no longer swallows the next key (#4748). A re-vendored copy
// without the patch would silently bring the bug back: re-apply the patch, or drop the copy once
// a winit 0.30 release has the fix.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let view = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/macos/view.rs")).unwrap();
assert!(view.contains("if pending_commit && ime_enabled && !is_control {"), "insertText commits only while marked text is left");
assert!(view.contains("self.ivars().pending_commit.set(true);"), "setMarkedText no longer starts a pending commit");
assert!(view.contains("self.ivars().in_key_down.set(true);"), "a commit outside a key press swallows the next key again");
assert!(view.contains("NSString::from_str(\"NSUnderline\")"), "validAttributesForMarkedText lost the underline attribute");
}

View File

@ -0,0 +1,16 @@
//! vendor/winit patch (7) (vendor/README.md).
#[test]
fn winit_discards_the_macos_composition_when_ime_is_disallowed() {
// egui disallows IME when a text field loses focus. winit 0.30.13 then forgets the marked text
// but doesn't tell the input method, which keeps the composition and continues it once IME is
// allowed again: the abandoned syllable comes back in the next field. vendor/winit calls
// `discardMarkedText` first (rust-windowing/winit#4745). A re-vendored copy without the patch
// would silently bring the bug back: re-apply the patch, or drop the copy once a winit 0.30
// release has the fix.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let view = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/macos/view.rs")).unwrap();
let (_, set_ime_allowed) = view.split_once("pub(super) fn set_ime_allowed(").expect("WinitView::set_ime_allowed");
let set_ime_allowed = set_ime_allowed.split_once("\n }\n").map_or(set_ime_allowed, |(body, _)| body);
assert!(set_ime_allowed.contains("input_context.discardMarkedText();"), "disallowing IME leaves the input method's composition");
}

View File

@ -0,0 +1,21 @@
//! vendor/winit patch (10) (vendor/README.md).
#[test]
fn winit_retries_the_first_apple_korean_key_once() {
// On the first key in a freshly started app, Apple Korean can answer with `insertText` and the
// raw compatibility jamo instead of starting a composition, so typing g k s in PdfCraft gave
// ㅎㅏㄴ instead of 한. vendor/winit interprets that one key again before sending it to the
// app, under the conditions `ImeStartup` checks (rust-windowing/winit#4744). A re-vendored copy
// without the patch would silently bring the bug back: re-apply the patch, or drop the copy once
// a winit 0.30 release has the fix.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let macos = root.join("vendor/winit/src/platform_impl/macos");
let policy = std::fs::read_to_string(macos.join("ime_startup.rs")).expect("vendor/winit lost ime_startup.rs");
assert!(policy.contains("source.starts_with(\"com.apple.inputmethod.Korean.\")"), "the retry is no longer limited to Apple Korean");
let view = std::fs::read_to_string(macos.join("view.rs")).unwrap();
assert!(
view.contains("let retry = self.ivars().ime_startup.borrow_mut().finish(serial, same_context);"),
"keyDown no longer retries the first key"
);
assert!(view.contains("self.ivars().ime_startup.borrow_mut().insert(&string);"), "insertText no longer reports to the retry policy");
}

View File

@ -0,0 +1,17 @@
//! vendor/winit patch (9) (vendor/README.md).
#[test]
fn winit_sends_a_key_committed_through_macos_ime_only_once() {
// Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`,
// `insertText(" ")` and `setMarkedText("")`. The last call resets winit 0.30.13's IME state, so
// the key went to the app both as `Ime::Commit(" ")` and as `KeyboardInput` with text " ", and
// PdfCraft's text fields got two spaces. vendor/winit doesn't send `KeyboardInput` for a key
// whose text was committed, unless AppKit forwards it as a command (Enter) (the goal of
// rust-windowing/winit#4478). A re-vendored copy without the patch would silently bring the bug
// back: re-apply the patch, or drop the copy once a winit 0.30 release has the fix.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let view = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/macos/view.rs")).unwrap();
assert!(view.contains("self.ivars().committed_in_key_down.set(true);"), "a commit in keyDown isn't recorded");
let consumed = "let had_ime_input = self.ivars().committed_in_key_down.replace(false) || had_ime_input;";
assert!(view.contains(consumed), "a key committed through IME is sent again as KeyboardInput");
}

View File

@ -0,0 +1,18 @@
//! vendor/winit patch (2) (vendor/README.md).
#[test]
fn winit_keeps_the_wayland_text_input_until_the_seat_goes() {
// winit 0.30.13 destroys a Wayland seat's text input whenever the seat loses any capability (a
// touch screen or tablet unplugged, a remote-desktop session ending) and recreates it only when
// a capability is added, so IME stops working in a running PdfCraft until it is restarted.
// vendor/winit destroys it with the seat instead (rust-windowing/winit#4747). A re-vendored copy
// without the patch would silently bring the bug back: re-apply the patch, or drop the copy once
// a winit 0.30 release has the fix.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let seat = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/linux/wayland/seat/mod.rs")).unwrap();
let (before_remove_seat, remove_seat) = seat.split_once("fn remove_seat(").expect("SeatHandler::remove_seat");
let (_, remove_capability) = before_remove_seat.split_once("fn remove_capability(").expect("SeatHandler::remove_capability");
let remove_seat = remove_seat.split_once("\n }\n").map_or(remove_seat, |(body, _)| body);
assert!(!remove_capability.contains("text_input.destroy()"), "losing a capability destroys the seat's text input again");
assert!(remove_seat.contains("text_input.destroy()"), "removing the seat no longer destroys its text input");
}

View File

@ -0,0 +1,18 @@
//! vendor/winit patch (4) (vendor/README.md).
#[test]
fn winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() {
// eframe creates PdfCraft's window hidden, restores the saved maximized state and shows the
// window once its first frame is drawn. winit 0.30.13 applies the maximized state with
// `ShowWindow(SW_MAXIMIZE)`, which shows a hidden window, so a window closed maximized came back
// unpainted (white) before its first frame and could lose its focus. vendor/winit maximizes a
// hidden window when it is shown, with one `SW_MAXIMIZE` (rust-windowing/winit#4587). A
// re-vendored copy without the patch would silently bring the bug back: re-apply the patch, or
// drop the copy once a winit 0.30 release has the fix.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let state = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/window_state.rs")).unwrap();
let shown_maximized = "if new.contains(WindowFlags::MAXIMIZED) && diff.contains(WindowFlags::VISIBLE) {\n SW_MAXIMIZE";
assert!(state.contains(shown_maximized), "a hidden window shown while maximized is shown, then maximized again");
let maximized_once_visible = "&& new.contains(WindowFlags::VISIBLE)\n && !diff.contains(WindowFlags::VISIBLE)\n {";
assert!(state.contains(maximized_once_visible), "maximizing a hidden window shows it again");
}

View File

@ -0,0 +1,17 @@
//! vendor/winit patch (3) (vendor/README.md).
#[test]
fn winit_reads_the_windows_ime_cursor_only_when_it_is_reported() {
// winit 0.30.13 reads `GCS_CURSORPOS` for every composition without a target clause, whether
// or not `WM_IME_COMPOSITION` says the IME set it. The Microsoft Korean IME never sets it, so
// the composition's cursor read as 0, before the syllable being composed. vendor/winit puts it
// at the end of the composition instead (rust-windowing/winit#4746). A re-vendored copy without
// the patch would silently bring the bug back: re-apply the patch, or drop the copy once a winit
// 0.30 release has the fix.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let windows = root.join("vendor/winit/src/platform_impl/windows");
let ime = std::fs::read_to_string(windows.join("ime.rs")).unwrap();
assert!(ime.contains("if composition_flags & GCS_CURSORPOS != 0 {"), "vendor/winit reads GCS_CURSORPOS unconditionally again");
let event_loop = std::fs::read_to_string(windows.join("event_loop.rs")).unwrap();
assert!(event_loop.contains("get_composing_text_and_cursor(lparam as u32)"), "WM_IME_COMPOSITION no longer passes its flags on");
}

View File

@ -0,0 +1,14 @@
#[test]
fn winit_carries_the_x11_server_dpi_fallback() {
// The behavior tests live beside the policy in winit (util::randr::tests).
// This guards PdfCraft's vendored copy, which workspace tests do not test itself.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let randr = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/linux/x11/util/randr.rs")).unwrap();
assert!(randr.contains("let screen = self.default_root();"));
assert!(randr.contains("default_dpi_factor(\n self.get_xft_dpi(),"));
assert!(randr.contains("screen.width_in_millimeters.into()"));
assert!(randr.contains("calc_dpi_factor(screen_pixels, screen_mm)"));
assert!(randr.contains("server_dpi_wins_over_the_panels_physical_density"));
let desktop = std::fs::read_to_string(root.join("packaging/linux/ai.storyteller.pdfcraft.desktop")).unwrap();
assert!(!desktop.contains("WINIT_X11_SCALE_FACTOR"));
}

View File

@ -0,0 +1,20 @@
//! vendor/winit patch (5) (vendor/README.md).
#[test]
fn winit_focuses_the_x11_input_context_it_replaces() {
// egui allows IME when a text field gains focus and disallows it when the field loses focus.
// On X11, winit 0.30.13 replaces the window's input context each time and leaves focusing the
// new one to the next focus event, which never comes while PdfCraft's window keeps the focus:
// the input method doesn't follow the field that has it. vendor/winit focuses the replacement
// once the queued requests are handled (rust-windowing/winit#4727). A re-vendored copy without
// the patch would silently bring the bug back: re-apply the patch, or drop the copy once a winit
// 0.30 release has the fix.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let x11 = root.join("vendor/winit/src/platform_impl/linux/x11");
let ime = std::fs::read_to_string(x11.join("ime/mod.rs")).unwrap();
let set_ime_allowed = "allowed: bool,\n ) -> Result<bool, ImeContextCreationError> {";
assert!(ime.contains(set_ime_allowed), "set_ime_allowed no longer says whether it replaced the context");
let events = std::fs::read_to_string(x11.join("event_processor.rs")).unwrap();
assert!(events.contains("Ok(true) => ime_focus = Some(window_id),"), "a replaced input context is not focused");
assert!(events.contains("let _ = ime.get_mut().focus(window_id);"), "a replaced input context is not focused");
}

View File

@ -0,0 +1,31 @@
PdfCraft PDF document icon (pdfcraft-document.svg and pdfcraft-document.ico).
SPDX-License-Identifier: ISC AND (MIT OR Apache-2.0)
The document outline and fold are adapted from Lucide file-text (lucide-static 1.49.0):
https://github.com/lucide-icons/lucide
The original is kept in assets/icons/file-text.svg.
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
The badge, outlined PDF lettering, colours and composition are original work
by the PdfCraft contributors. Copyright (c) 2026 PdfCraft contributors.
Licensed under either the MIT License or the Apache License, Version 2.0,
at your option. Full texts are LICENSE-MIT and LICENSE-APACHE at the
repository root, and beside this file in Windows distributions.
The icon uses no typeface or external image. Regenerate the ICO with
packaging/icons.sh; every asset and its SHA-256 is recorded in ATTRIBUTION.toml.

View File

@ -33,6 +33,8 @@ Licence: [LICENSE.txt](LICENSE.txt) (`MIT OR Apache-2.0`, like the repo).
| `pdfcraft-1024.png` | 1024 px on Apple's grid; also the runtime Dock icon on macOS |
| `pdfcraft.icns` | macOS icon (16–1024 px) |
| `pdfcraft.ico` | Windows icon (16–256 px), embedded in `pdfcraft.exe` by `apps/pdfcraft/build.rs` |
| `pdfcraft-document.svg` | Windows PDF file icon master: a Lucide document outline, green badge and outlined PDF lettering; no font dependency |
| `pdfcraft-document.ico` | Windows PDF file icon (16–256 px), embedded as resource 2 in `pdfcraft.exe` |
| `hicolor/<n>x<n>/apps/ai.storyteller.pdfcraft.png` | Linux hicolor theme, 16–512 px; the 256 px one is the runtime icon on Windows and Linux |
| `hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | Linux scalable icon (copy of the master) |
@ -40,6 +42,12 @@ Where it shows: `apps/pdfcraft/src/main.rs` sets the window icon (Dock, taskbar,
Wayland app id `ai.storyteller.pdfcraft`; `packaging/linux/ai.storyteller.pdfcraft.desktop` names the
hicolor icon.
Windows PDF files use resource 2 (`"pdfcraft.exe",-2`) when PdfCraft is chosen as the default
reader; the app keeps resource 1. The document outline is adapted from Lucide `file-text`
1.49.0 (ISC); its badge and outlined lettering are original (`MIT OR Apache-2.0`), covered by
[LICENSE-document.txt](LICENSE-document.txt). Both icons are embedded, requiring no runtime
sidecar. This provides a file type icon; first-page thumbnails still need a thumbnail provider.
## Regenerate
```sh

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -0,0 +1,16 @@
<!-- Document outline: Lucide file-text, lucide-static 1.49.0, ISC.
PDF badge and outlined lettering: PdfCraft contributors, MIT OR Apache-2.0.
No fonts or external images. See LICENSE-document.txt and ATTRIBUTION.toml. -->
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<g transform="translate(-2 -2) scale(2.8)" stroke="#0b0b0c" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path fill="#efe9dc" d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
<path fill="#c8ece4" d="M14 2v5a1 1 0 0 0 1 1h5" />
<path fill="none" stroke="#12a58a" d="M8 12h8M8 15h5" />
</g>
<rect x="3" y="34" width="56" height="23" rx="4" fill="#087f6b" />
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 50V41h5a3 3 0 0 1 0 6h-5" />
<path d="M27 41v9h3a4.5 4.5 0 0 0 0-9z" />
<path d="M42 50V41h8M42 45h6" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1023 B

View File

@ -20,6 +20,9 @@ environment variable if you set one.
Inter and JetBrains Mono fonts).
2. **Chrome.** Headless Chrome prints the page to PDF with
`--headless=new --no-pdf-header-footer --generate-pdf-document-outline --print-to-pdf`.
It runs with a throwaway `--user-data-dir` and
`--disable-features=MacAppCodeSignClone`, so no code-sign clones are left
behind on macOS.
Skia produces 12 tagged pages with embedded font subsets. Variable
system-ui weights come out as Type 3 fonts and emoji as Type 3 bitmap
glyphs. The pages also get shadings, blend modes, soft masks, tiling

View File

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m14 10 7-7" />
<path d="M20 10h-6V4" />
<path d="m3 21 7-7" />
<path d="M4 14h6v6" />
</svg>

After

Width:  |  Height:  |  Size: 311 B

15
assets/icons/monitor.svg Normal file
View File

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>

After

Width:  |  Height:  |  Size: 346 B

View File

@ -2,3 +2,14 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
# With the theme's widget visuals egui draws a checkbox's box as a circle, like a radio button,
# and on the light theme neither has an outline or a fill that shows on a white panel.
disallowed-methods = [
{ path = "egui::Ui::checkbox", reason = "use checkbox from crates/ui-egui/src/widgets.rs, whose box is a square" },
{ path = "egui::Checkbox::new", reason = "use checkbox from crates/ui-egui/src/widgets.rs, whose box is a square" },
{ path = "egui::Checkbox::without_text", reason = "use checkbox_without_text from crates/ui-egui/src/widgets.rs, whose box is a square" },
{ path = "egui::Ui::radio", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" },
{ path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" },
{ path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" },
]

View File

@ -2,23 +2,23 @@
"schema": 1,
"repo": "storytold/pdfcraft",
"branch": "main",
"head": "49c6e2ec5437a355da4e9dadd9cc2db3476b5cf8",
"head_date": "2026-10-10T00:56:13Z",
"commits": 493,
"head": "b8ddf71e30959dc8a5ca4b66c40b8fcaea68cf76",
"head_date": "2026-10-10T21:42:20Z",
"commits": 627,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 77,
"commits": 258,
"lines_added": 221771,
"lines_deleted": 11405,
"prs": 78,
"commits": 261,
"lines_added": 224353,
"lines_deleted": 11715,
"binary_added": 81,
"binary_deleted": 25,
"first_commit": "2026-09-30T09:17:08Z",
"last_commit": "2026-10-09T22:42:23Z"
"last_commit": "2026-10-10T18:23:28Z"
},
{
"login": "storybored379",
@ -37,14 +37,14 @@
"login": "justinjohn0306",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 2,
"lines_added": 2936,
"lines_deleted": 159,
"prs": 4,
"commits": 3,
"lines_added": 3012,
"lines_deleted": 161,
"binary_added": 1,
"binary_deleted": 0,
"first_commit": "2026-10-05T11:48:23Z",
"last_commit": "2026-10-08T03:02:40Z"
"last_commit": "2026-10-10T19:39:15Z"
},
{
"login": "midasdf",
@ -89,14 +89,14 @@
"login": "jcl-bit",
"people_entry": true,
"github_name": null,
"prs": 15,
"commits": 15,
"lines_added": 11099,
"lines_deleted": 348,
"prs": 16,
"commits": 16,
"lines_added": 11248,
"lines_deleted": 365,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T01:12:32Z",
"last_commit": "2026-10-09T23:09:23Z"
"last_commit": "2026-10-10T10:14:36Z"
},
{
"login": "rutkk",
@ -115,14 +115,14 @@
"login": "ShauryaSwarup",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 4,
"prs": 2,
"commits": 2,
"lines_added": 59,
"lines_deleted": 1,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T01:12:50Z",
"last_commit": "2026-10-08T01:12:50Z"
"last_commit": "2026-10-10T10:04:44Z"
},
{
"login": "cmoro-deusto",
@ -232,14 +232,14 @@
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 1003,
"lines_deleted": 55,
"prs": 12,
"commits": 12,
"lines_added": 2199,
"lines_deleted": 187,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T01:37:28Z",
"last_commit": "2026-10-08T16:49:25Z"
"last_commit": "2026-10-10T21:39:39Z"
},
{
"login": "pauliusiv123-rgb",
@ -336,14 +336,14 @@
"login": "Mawfyy",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 327,
"lines_deleted": 28,
"prs": 5,
"commits": 5,
"lines_added": 458,
"lines_deleted": 31,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T03:14:45Z",
"last_commit": "2026-10-08T20:46:22Z"
"last_commit": "2026-10-10T01:26:23Z"
},
{
"login": "ysrdevs",
@ -362,14 +362,14 @@
"login": "CQMHV",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 661,
"lines_deleted": 75,
"prs": 4,
"commits": 4,
"lines_added": 745,
"lines_deleted": 86,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T03:45:02Z",
"last_commit": "2026-10-08T17:40:30Z"
"last_commit": "2026-10-10T19:46:22Z"
},
{
"login": "neavo",
@ -453,14 +453,14 @@
"login": "cyfung1031",
"people_entry": true,
"github_name": null,
"prs": 21,
"commits": 21,
"lines_added": 66562,
"lines_deleted": 205,
"prs": 63,
"commits": 63,
"lines_added": 71877,
"lines_deleted": 726,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:00:21Z",
"last_commit": "2026-10-09T18:12:43Z"
"last_commit": "2026-10-10T21:17:13Z"
},
{
"login": "XusBadia",
@ -505,14 +505,14 @@
"login": "EpsilonLLC",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 326,
"lines_deleted": 30,
"prs": 2,
"commits": 2,
"lines_added": 676,
"lines_deleted": 103,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T17:02:52Z",
"last_commit": "2026-10-08T17:02:52Z"
"last_commit": "2026-10-10T20:25:24Z"
},
{
"login": "sanyamsingh001",
@ -544,14 +544,14 @@
"login": "mlkonrad",
"people_entry": true,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 3129,
"lines_deleted": 206,
"prs": 6,
"commits": 6,
"lines_added": 3847,
"lines_deleted": 268,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T19:37:10Z",
"last_commit": "2026-10-08T22:46:34Z"
"last_commit": "2026-10-10T20:41:27Z"
},
{
"login": "Fazmin",
@ -596,14 +596,14 @@
"login": "MarekSpodBiedry",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 773,
"lines_deleted": 113,
"prs": 15,
"commits": 15,
"lines_added": 1317,
"lines_deleted": 135,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T21:29:59Z",
"last_commit": "2026-10-09T13:14:55Z"
"last_commit": "2026-10-10T19:50:20Z"
},
{
"login": "MaksPV",
@ -778,14 +778,14 @@
"login": "LloydNicholson",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 646,
"lines_deleted": 66,
"prs": 8,
"commits": 8,
"lines_added": 1204,
"lines_deleted": 119,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T16:19:10Z",
"last_commit": "2026-10-09T18:44:11Z"
"last_commit": "2026-10-10T21:42:20Z"
},
{
"login": "martinjohuber",
@ -804,14 +804,14 @@
"login": "bartalos86",
"people_entry": false,
"github_name": null,
"prs": 6,
"commits": 6,
"lines_added": 3873,
"lines_deleted": 176,
"prs": 11,
"commits": 11,
"lines_added": 5353,
"lines_deleted": 323,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T16:28:06Z",
"last_commit": "2026-10-09T22:45:54Z"
"last_commit": "2026-10-10T12:32:09Z"
},
{
"login": "dmatviichuk",
@ -895,14 +895,14 @@
"login": "tanish2k09",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 698,
"lines_deleted": 3,
"prs": 3,
"commits": 3,
"lines_added": 1078,
"lines_deleted": 36,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T18:44:37Z",
"last_commit": "2026-10-09T18:46:42Z"
"last_commit": "2026-10-10T01:26:54Z"
},
{
"login": "thorstenhirsch",
@ -934,27 +934,27 @@
"login": "haranishi",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 158,
"lines_deleted": 2,
"prs": 4,
"commits": 4,
"lines_added": 875,
"lines_deleted": 25,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T22:45:11Z",
"last_commit": "2026-10-09T22:45:11Z"
"last_commit": "2026-10-10T19:54:26Z"
},
{
"login": "Tevinhead",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 312,
"lines_deleted": 21,
"prs": 5,
"commits": 5,
"lines_added": 5102,
"lines_deleted": 310,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T22:52:13Z",
"last_commit": "2026-10-09T22:52:13Z"
"last_commit": "2026-10-10T21:38:34Z"
},
{
"login": "rschuerer",
@ -1012,14 +1012,274 @@
"login": "edurbs",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2114,
"lines_deleted": 5,
"prs": 3,
"commits": 3,
"lines_added": 2517,
"lines_deleted": 41,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T00:56:13Z",
"last_commit": "2026-10-10T00:56:13Z"
"last_commit": "2026-10-10T02:39:36Z"
},
{
"login": "mmmaarian03-crypto",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 173,
"lines_deleted": 40,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T10:04:32Z",
"last_commit": "2026-10-10T10:19:07Z"
},
{
"login": "Hathor875",
"people_entry": true,
"github_name": "Krzysztof Cieślik",
"prs": 3,
"commits": 3,
"lines_added": 92,
"lines_deleted": 6,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T10:12:10Z",
"last_commit": "2026-10-10T19:11:19Z"
},
{
"login": "oussama-dalhi",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 20,
"lines_deleted": 1,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T10:12:57Z",
"last_commit": "2026-10-10T10:12:57Z"
},
{
"login": "Toddpe",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 392,
"lines_deleted": 207,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T10:15:06Z",
"last_commit": "2026-10-10T10:32:06Z"
},
{
"login": "benimaru327",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 429,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T10:23:11Z",
"last_commit": "2026-10-10T10:23:11Z"
},
{
"login": "abdelhaqelamraoui",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2661,
"lines_deleted": 42,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T10:36:54Z",
"last_commit": "2026-10-10T10:36:54Z"
},
{
"login": "silverhadch",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 650,
"lines_deleted": 62,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T12:19:45Z",
"last_commit": "2026-10-10T20:28:29Z"
},
{
"login": "muraa-p",
"people_entry": false,
"github_name": null,
"prs": 7,
"commits": 7,
"lines_added": 225,
"lines_deleted": 24,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T19:06:02Z",
"last_commit": "2026-10-10T19:31:35Z"
},
{
"login": "ch0kemeister",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 147,
"lines_deleted": 32,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T19:07:38Z",
"last_commit": "2026-10-10T19:50:55Z"
},
{
"login": "Ni-zav",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 41,
"lines_deleted": 1,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T19:29:47Z",
"last_commit": "2026-10-10T19:29:47Z"
},
{
"login": "snipeTR",
"people_entry": false,
"github_name": null,
"prs": 5,
"commits": 5,
"lines_added": 495,
"lines_deleted": 66,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T19:32:07Z",
"last_commit": "2026-10-10T21:10:39Z"
},
{
"login": "gyurix",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 188,
"lines_deleted": 5,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T19:47:33Z",
"last_commit": "2026-10-10T19:47:33Z"
},
{
"login": "CyberFox-81",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 211,
"lines_deleted": 6,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T19:50:06Z",
"last_commit": "2026-10-10T19:50:06Z"
},
{
"login": "A-viral-dev",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 7,
"lines_deleted": 8,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T19:50:42Z",
"last_commit": "2026-10-10T19:50:42Z"
},
{
"login": "taylorandrew07-lab",
"people_entry": false,
"github_name": null,
"prs": 5,
"commits": 5,
"lines_added": 6295,
"lines_deleted": 389,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T20:20:50Z",
"last_commit": "2026-10-10T21:01:08Z"
},
{
"login": "80aa08",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2227,
"lines_deleted": 3,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T21:03:47Z",
"last_commit": "2026-10-10T21:03:47Z"
},
{
"login": "jay-tau",
"people_entry": true,
"github_name": "Joel Tony",
"prs": 1,
"commits": 1,
"lines_added": 65,
"lines_deleted": 9,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T21:04:14Z",
"last_commit": "2026-10-10T21:04:14Z"
},
{
"login": "mitul72",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 293,
"lines_deleted": 15,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T21:13:52Z",
"last_commit": "2026-10-10T21:13:52Z"
},
{
"login": "joeabouserhal",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 127,
"lines_deleted": 12,
"binary_added": 1,
"binary_deleted": 0,
"first_commit": "2026-10-10T21:21:30Z",
"last_commit": "2026-10-10T21:21:30Z"
},
{
"login": "dsskaggs-ai",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 41,
"lines_deleted": 3,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-10T21:39:55Z",
"last_commit": "2026-10-10T21:39:55Z"
}
],
"models": [
@ -1028,45 +1288,54 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 392,
"lines_added": 299122,
"lines_deleted": 16822
"commits": 452,
"lines_added": 320722,
"lines_deleted": 18683
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 43,
"lines_added": 20596,
"lines_deleted": 2467
"commits": 52,
"lines_added": 28322,
"lines_deleted": 3099
},
{
"id": "anthropic/claude-fable/5.1",
"company": "Anthropic",
"model": "Claude Fable",
"version": "5.1",
"commits": 9,
"lines_added": 1028,
"lines_deleted": 112
"commits": 13,
"lines_added": 1525,
"lines_deleted": 217
},
{
"id": "anthropic/claude-opus/5",
"company": "Anthropic",
"model": "Claude Opus",
"version": "5",
"commits": 9,
"lines_added": 3783,
"lines_deleted": 197
"commits": 10,
"lines_added": 4163,
"lines_deleted": 230
},
{
"id": "anthropic/claude-sonnet/5.5",
"company": "Anthropic",
"model": "Claude Sonnet",
"version": "5.5",
"commits": 4,
"lines_added": 2839,
"lines_deleted": 135
"commits": 7,
"lines_added": 3556,
"lines_deleted": 158
},
{
"id": "anthropic/claude-haiku/5.5",
"company": "Anthropic",
"model": "Claude Haiku",
"version": "5.5",
"commits": 6,
"lines_added": 1813,
"lines_deleted": 179
},
{
"id": "anthropic/claude",
@ -1082,9 +1351,9 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "4.8",
"commits": 1,
"lines_added": 1632,
"lines_deleted": 3
"commits": 2,
"lines_added": 1708,
"lines_deleted": 5
},
{
"id": "anthropic/claude-sonnet/5",

View File

@ -17,6 +17,9 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.YouseifUS]
sync_github_name = true
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
@ -67,3 +70,62 @@ sync_github_name = false
[people.Byxis]
sync_github_name = true
[people.Hathor875]
real_name = "Krzysztof Cieślik"
sync_github_name = true
[people.jay-tau]
sync_github_name = true
[people.Piohrun]
real_name = "Grzegorz Martens"
sync_github_name = true
[people.krinry]
sync_github_name = true
[people.arcanii]
real_name = "Bryan Mark"
display_name = "Arcanii"
sync_github_name = false
[people.atoslins]
real_name = "Atos Lins"
display_name = "Atos Lins"
sync_github_name = false
[people.mhadidi06]
display_name = "Mustafa Hadidi"
[people.LucasT-dev]
real_name = "Lucas"
display_name = "Lucas"
sync_github_name = true # use the name on your public GitHub profile as display name
[people.Losminek]
sync_github_name = true
[people.earlvanze]
sync_github_name = true
[people.rp6186030]
real_name = "Raj Patel"
sync_github_name = false
[people.yeniufuklar-com]
real_name = "Ufuk Efe"
display_name = "Ufuk Efe"
sync_github_name = true
[people.lollinoo]
sync_github_name = true
[people.JeremyYao]
real_name = "Jeremy Yao"
display_name = "Jeremy Yao"
sync_github_name = false
[people.webdevtodayjason]
display_name = "Jason Brashear"

View File

@ -14,4 +14,6 @@ workspace = true
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-content = { workspace = true }
pdfcraft-filters = { workspace = true }
pdfcraft-fonts = { workspace = true }
thiserror.workspace = true

View File

@ -4,7 +4,7 @@ Layer L4: accessibility (execution plan M12.3). Today: the Accessibility Checker
```rust
let report = check(&doc, &Options::default()); // 31 of 32 rules (colour contrast off)
let html = report_html(&report, "file.pdf", "2026-10-02");
let html = report_html(&report, "file.pdf", "2026-10-02", "en", &in_english); // or an interface's words
```
- **32 rules in 7 categories**, as in Acrobat's full check: Document (permission flag,

View File

@ -27,13 +27,13 @@ pub enum AltError {
Cos(#[from] pdfcraft_cos::CosError),
}
fn tree_root(doc: &Document) -> Option<Dict> {
pub(crate) fn tree_root(doc: &Document) -> Option<Dict> {
let cat = doc.root().and_then(|r| doc.get(r).as_dict().cloned())?;
cat.get(b"StructTreeRoot").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned())
}
/// The standard type of a structure type through the role map.
fn standard(doc: &Document, role_map: &Dict, s: &[u8]) -> Vec<u8> {
pub(crate) fn standard(doc: &Document, role_map: &Dict, s: &[u8]) -> Vec<u8> {
let mut t = s.to_vec();
for _ in 0..12 {
if crate::structure::is_standard(&t) {
@ -47,18 +47,28 @@ fn standard(doc: &Document, role_map: &Dict, s: &[u8]) -> Vec<u8> {
t
}
/// Indirect structure elements in document order, with their page.
fn elements(doc: &Document) -> Vec<(ObjRef, Dict, Option<ObjRef>)> {
/// An indirect structure element and its nearest indirect parent in tree order.
pub(crate) struct Element {
pub object: ObjRef,
pub dict: Dict,
pub page: Option<ObjRef>,
pub parent: Option<ObjRef>,
pub depth: usize,
}
/// Indirect structure elements in document order, with their page and hierarchy.
pub(crate) fn elements(doc: &Document) -> Vec<Element> {
let Some(root) = tree_root(doc) else { return Vec::new() };
let mut out = Vec::new();
let mut seen = HashSet::new();
let mut stack: Vec<(Object, Option<ObjRef>)> = root.get(b"K").map(|k| vec![(k.clone(), None)]).unwrap_or_default();
while let Some((k, pg)) = stack.pop() {
let mut stack: Vec<(Object, Option<ObjRef>, Option<ObjRef>, usize)> =
root.get(b"K").map(|k| vec![(k.clone(), None, None, 0)]).unwrap_or_default();
while let Some((k, pg, parent, depth)) = stack.pop() {
if out.len() > 1_000_000 {
break;
}
match k {
Object::Array(a) => stack.extend(a.into_iter().rev().map(|x| (x, pg))),
Object::Array(a) => stack.extend(a.into_iter().rev().map(|x| (x, pg, parent, depth))),
Object::Ref(r) => {
if !seen.insert(r) {
continue;
@ -67,16 +77,16 @@ fn elements(doc: &Document) -> Vec<(ObjRef, Dict, Option<ObjRef>)> {
if d.contains(b"S") {
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
if let Some(kids) = d.get(b"K") {
stack.push((kids.clone(), pg));
stack.push((kids.clone(), pg, Some(r), depth.saturating_add(1)));
}
out.push((r, d, pg));
out.push(Element { object: r, dict: d, page: pg, parent, depth });
}
}
Object::Dict(d) if d.contains(b"S") => {
// Direct elements can't be addressed; their indirect children still can.
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
if let Some(kids) = d.get(b"K") {
stack.push((kids.clone(), pg));
stack.push((kids.clone(), pg, parent, depth));
}
}
_ => {}
@ -105,7 +115,7 @@ fn mcids(d: &Dict) -> Vec<i64> {
out
}
fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
let Some(c) = page.get(b"Contents") else { return Vec::new() };
match &*doc.resolve(c) {
Object::Stream(s) => s.decoded().unwrap_or_default(),
@ -218,13 +228,13 @@ pub fn figures(doc: &Document) -> Vec<Figure> {
let index: HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
elements(doc)
.into_iter()
.filter(|(_, d, _)| d.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure"))
.map(|(obj, d, pg)| {
let page = pg.and_then(|p| index.get(&p).copied());
let alt = d.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
let ids: HashSet<i64> = mcids(&d).into_iter().collect();
.filter(|e| e.dict.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure"))
.map(|e| {
let page = e.page.and_then(|p| index.get(&p).copied());
let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
let ids: HashSet<i64> = mcids(&e.dict).into_iter().collect();
let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids));
Figure { obj, page, alt, bbox }
Figure { obj: e.object, page, alt, bbox }
})
.collect()
}
@ -258,7 +268,7 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError>
let d = check_figure(doc, obj)?;
let ids: HashSet<i64> = mcids(&d).into_iter().collect();
let pages = pdfcraft_model::pages(doc);
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|(r, _, _)| *r == obj).and_then(|(_, _, p)| p));
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page));
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) {
let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops;
let mut changed = false;

282
crates/a11y/src/autotag.rs Normal file
View File

@ -0,0 +1,282 @@
//! Create a valid, reviewable structure tree for PDFs whose painted content is text only.
//!
//! The writer refuses mixed content and existing tags. Each text object becomes a paragraph,
//! which can then be retagged with the existing tag-property tool after human review.
use pdfcraft_content::{Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
const MAX_PAGES: usize = 1_000;
const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024;
const MAX_OPERATORS: usize = 100_000;
const MAX_TEXT_OBJECTS: usize = 4_096;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AutoTagResult {
pub pages: usize,
pub paragraphs: usize,
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum AutoTagError {
#[error("the document already has a structure tree; review its existing tags instead")]
AlreadyTagged,
#[error("the document has no text objects to tag")]
NoText,
#[error("the document has too many pages to auto-tag in one edit (maximum {MAX_PAGES})")]
TooManyPages,
#[error("page {page}: {reason}")]
Unsupported { page: usize, reason: String },
#[error("the PDF has no document catalog")]
NoCatalog,
#[error(transparent)]
Cos(#[from] pdfcraft_cos::CosError),
}
struct Plan {
page: ObjRef,
ops: Vec<Op>,
spans: Vec<(usize, usize)>,
}
fn unsupported(page: usize, reason: impl Into<String>) -> AutoTagError {
AutoTagError::Unsupported { page: page + 1, reason: reason.into() }
}
fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> Result<Vec<u8>, AutoTagError> {
let Some(contents) = page.dict.get(b"Contents") else { return Ok(Vec::new()) };
let resolved = doc.resolve(contents);
let pieces: Vec<Object> = match &*resolved {
Object::Array(items) => items.clone(),
Object::Stream(_) => vec![contents.clone()],
_ => return Err(unsupported(index, "contents are not a stream or array of streams")),
};
let mut all = Vec::new();
for item in pieces {
let value = doc.resolve(&item);
let Object::Stream(stream) = &*value else { return Err(unsupported(index, "contents include a non-stream object")) };
let remaining = MAX_PAGE_BYTES.saturating_sub(all.len().saturating_add(1));
if remaining == 0 {
return Err(unsupported(index, "decoded content exceeds 16 MiB"));
}
let chain = stream.filters();
let decoded = if chain.is_empty() {
if stream.raw.len() > remaining {
return Err(unsupported(index, "decoded content exceeds 16 MiB"));
}
stream.raw.to_vec()
} else {
pdfcraft_filters::decode(&chain, &stream.raw, remaining)
.map_err(|e| unsupported(index, format!("content cannot be decoded strictly: {e}")))?
};
all.extend_from_slice(&decoded);
all.push(b'\n');
}
Ok(all)
}
fn harmless_outside(op: &[u8]) -> bool {
matches!(
op,
b"q" | b"Q"
| b"cm"
| b"w"
| b"J"
| b"j"
| b"M"
| b"d"
| b"ri"
| b"i"
| b"gs"
| b"G"
| b"g"
| b"RG"
| b"rg"
| b"K"
| b"k"
| b"CS"
| b"cs"
| b"SC"
| b"sc"
| b"SCN"
| b"scn"
)
}
fn harmless_inside(op: &[u8]) -> bool {
matches!(
op,
b"Tf"
| b"Tc"
| b"Tw"
| b"Tz"
| b"TL"
| b"Tr"
| b"Ts"
| b"Td"
| b"TD"
| b"Tm"
| b"T*"
| b"G"
| b"g"
| b"RG"
| b"rg"
| b"K"
| b"k"
| b"CS"
| b"cs"
| b"SC"
| b"sc"
| b"SCN"
| b"scn"
)
}
fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
let root = doc.root().ok_or(AutoTagError::NoCatalog)?;
let catalog = doc.get(root);
let catalog = catalog.as_dict().ok_or(AutoTagError::NoCatalog)?;
if catalog.contains(b"StructTreeRoot") {
return Err(AutoTagError::AlreadyTagged);
}
if catalog.contains(b"AcroForm") {
return Err(unsupported(0, "form fields need their own tags; this text-only action cannot tag them"));
}
let pages = pdfcraft_model::pages(doc);
if pages.len() > MAX_PAGES {
return Err(AutoTagError::TooManyPages);
}
let mut out = Vec::with_capacity(pages.len());
let mut total = 0usize;
for (index, page) in pages.iter().enumerate() {
if page.dict.contains(b"StructParents") {
return Err(unsupported(index, "page already has a structure parent key"));
}
if page.dict.get(b"Annots").is_some_and(|a| match &*doc.resolve(a) {
Object::Array(items) => !items.is_empty(),
_ => true,
}) {
return Err(unsupported(index, "annotations need separate structure tags"));
}
let bytes = content_bytes(doc, page, index)?;
let parsed = parse(&bytes);
if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS {
return Err(unsupported(index, "content has malformed or too many operators"));
}
let mut start = None;
let mut has_text = false;
let mut spans = Vec::new();
for (at, op) in parsed.ops.iter().enumerate() {
match op.op.as_slice() {
b"BT" if start.is_none() => {
start = Some(at);
has_text = false;
}
b"ET" => {
let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) };
if has_text {
spans.push((first, at));
if spans.len() > MAX_TEXT_OBJECTS {
return Err(unsupported(index, "too many text objects (maximum 4096)"));
}
}
}
b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true,
name if start.is_some() && harmless_inside(name) => {}
name if start.is_none() && harmless_outside(name) => {}
_ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))),
}
}
if start.is_some() {
return Err(unsupported(index, "unterminated BT text object"));
}
total = total.saturating_add(spans.len());
out.push(Plan { page: page.obj, ops: parsed.ops, spans });
}
if total == 0 {
return Err(AutoTagError::NoText);
}
Ok(out)
}
/// Tag every text object in an untagged, text-only PDF as a paragraph. The full structure tree,
/// parent tree and marked-content IDs are written together. Mixed content is refused before any
/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles.
pub fn autotag_text(doc: &mut Document) -> Result<AutoTagResult, AutoTagError> {
let plans = plan(doc)?;
let mut next = doc.clone();
let catalog = next.root().ok_or(AutoTagError::NoCatalog)?;
let root = next.add(Object::Null);
let document = next.add(Object::Null);
let mut children = Vec::new();
let mut numbers = Vec::new();
let mut tagged_pages = 0usize;
let mut paragraphs = 0usize;
for page in plans {
if page.spans.is_empty() {
continue;
}
let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?;
let mut parents = Vec::with_capacity(page.spans.len());
let mut starts = std::collections::HashMap::new();
let mut ends = std::collections::HashSet::new();
for (mcid, (first, last)) in page.spans.iter().enumerate() {
let mut element = Dict::new();
element.set(b"Type".to_vec(), Object::name("StructElem"));
element.set(b"S".to_vec(), Object::name("P"));
element.set(b"P".to_vec(), Object::Ref(document));
element.set(b"Pg".to_vec(), Object::Ref(page.page));
element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?));
let reference = next.add(element);
parents.push(Object::Ref(reference));
children.push(Object::Ref(reference));
starts.insert(*first, mcid);
ends.insert(*last);
paragraphs = paragraphs.saturating_add(1);
}
let mut marked = Vec::with_capacity(page.ops.len().saturating_add(page.spans.len().saturating_mul(2)));
for (at, op) in page.ops.into_iter().enumerate() {
if let Some(mcid) = starts.get(&at) {
let mut properties = Dict::new();
properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?));
marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)]));
}
marked.push(op);
if ends.contains(&at) {
marked.push(Op::new("EMC", Vec::new()));
}
}
let stream = next.add(Stream::flate(Dict::new(), &serialize_ops(&marked)));
next.update_dict(page.page, |d| {
d.set(b"Contents".to_vec(), Object::Ref(stream));
d.set(b"StructParents".to_vec(), Object::Int(key));
})?;
numbers.push(Object::Int(key));
numbers.push(Object::Array(parents));
tagged_pages += 1;
}
let mut tree = Dict::new();
tree.set(b"Nums".to_vec(), Object::Array(numbers));
let parent_tree = next.add(tree);
let mut doc_element = Dict::new();
doc_element.set(b"Type".to_vec(), Object::name("StructElem"));
doc_element.set(b"S".to_vec(), Object::name("Document"));
doc_element.set(b"P".to_vec(), Object::Ref(root));
doc_element.set(b"K".to_vec(), Object::Array(children));
next.set(document, doc_element);
let mut structure = Dict::new();
structure.set(b"Type".to_vec(), Object::name("StructTreeRoot"));
structure.set(b"K".to_vec(), Object::Ref(document));
structure.set(b"ParentTree".to_vec(), Object::Ref(parent_tree));
structure.set(b"ParentTreeNextKey".to_vec(), Object::Int(i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?));
next.set(root, structure);
let mark_info = next.get(catalog).as_dict().and_then(|d| d.get(b"MarkInfo")).map(|m| next.resolve(m)).and_then(|m| m.as_dict().cloned());
let mut mark_info = mark_info.unwrap_or_default();
mark_info.set(b"Marked".to_vec(), Object::Bool(true));
next.update_dict(catalog, |d| {
d.set(b"StructTreeRoot".to_vec(), Object::Ref(root));
d.set(b"MarkInfo".to_vec(), Object::Dict(mark_info));
})?;
*doc = next;
Ok(AutoTagResult { pages: tagged_pages, paragraphs })
}

View File

@ -1,12 +1,14 @@
//! The Document, Page Content and Forms rules: catalog settings, page content (tagged or
//! artifact, text, images, fonts) and annotations.
//!
//! Every sentence a rule here can say is listed in [`crate::WORDINGS`], so interfaces can say it in
//! their own language; a test in this crate keeps that list complete.
use std::collections::{BTreeMap, BTreeSet, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use pdfcraft_model::Page;
use crate::structure::Tree;
use crate::{Finding, Rule};
/// What the page content holds, per checked page.
@ -145,8 +147,8 @@ fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
}
fn doc_finding(message: impl Into<String>) -> Vec<Finding> {
vec![Finding { page: None, message: message.into() }]
fn doc_finding(sentence: &'static str) -> Vec<Finding> {
vec![Finding::plain(None, sentence)]
}
pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &Scan) -> Vec<Finding> {
@ -168,10 +170,10 @@ pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &S
let marked = cat.get(b"MarkInfo").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().and_then(|d| d.get(b"Marked").cloned()));
let mut out = Vec::new();
if !cat.contains(b"StructTreeRoot") {
out.push(Finding { page: None, message: "The document has no tags (structure tree)".into() });
out.push(Finding::plain(None, "The document has no tags (structure tree)"));
}
if !matches!(marked, Some(Object::Bool(true))) {
out.push(Finding { page: None, message: "The document isn't marked as tagged (MarkInfo Marked)".into() });
out.push(Finding::plain(None, "The document isn't marked as tagged (MarkInfo Marked)"));
}
out
}
@ -186,7 +188,7 @@ pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &S
let mut out = Vec::new();
let info = doc.trailer().get(b"Info").map(|i| doc.resolve(i)).and_then(|i| i.as_dict().cloned()).unwrap_or_default();
if text_of(doc, &info, b"Title").is_none() {
out.push(Finding { page: None, message: "The document has no title".into() });
out.push(Finding::plain(None, "The document has no title"));
}
let shows = cat
.get(b"ViewerPreferences")
@ -194,13 +196,17 @@ pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &S
.and_then(|v| v.as_dict().and_then(|d| d.get(b"DisplayDocTitle").map(|x| matches!(&*doc.resolve(x), Object::Bool(true)))))
.unwrap_or(false);
if !shows {
out.push(Finding { page: None, message: "The window shows the file name, not the title (Initial View > Show)".into() });
out.push(Finding::plain(None, "The window shows the file name, not the title (Initial View > Show)"));
}
out
}
Rule::Bookmarks => {
let has = cat.get(b"Outlines").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().map(|d| d.contains(b"First"))).unwrap_or(false);
if pages.len() > 20 && !has { doc_finding(format!("{} pages and no bookmarks", pages.len())) } else { Vec::new() }
if pages.len() > 20 && !has {
vec![Finding::worded(None, "{n} pages and no bookmarks", vec![("{n}", pages.len().to_string())])]
} else {
Vec::new()
}
}
_ => Vec::new(),
}
@ -212,9 +218,22 @@ fn subtype_name(d: &Dict) -> String {
String::from_utf8_lossy(d.name(b"Subtype").unwrap_or(b"Unknown")).into_owned()
}
pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tree: &Tree) -> Vec<Finding> {
/// An annotation's rectangle is already in page user space. Ignore missing, malformed and
/// degenerate rectangles rather than sending the checker to an arbitrary place on the page.
fn annotation_rect(doc: &Document, annot: &Dict) -> Option<[f64; 4]> {
let rect = annot.get(b"Rect").map(|r| doc.resolve(r))?;
let values = rect.as_array()?;
let [x0, y0, x1, y1] = values.as_slice() else { return None };
let [x0, y0, x1, y1] = [x0, y0, x1, y1].map(|v| doc.resolve(v).as_f64());
let (x0, y0, x1, y1) = (x0?, y0?, x1?, y1?);
if ![x0, y0, x1, y1].iter().all(|v| v.is_finite()) || x0 == x1 || y0 == y1 {
return None;
}
Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
}
pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tagged: bool) -> Vec<Finding> {
let mut out = Vec::new();
let tagged = tree.exists;
let annots = |p: &Page| -> Vec<(ObjRef, Dict)> {
let a = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
a.iter()
@ -228,7 +247,10 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
return doc_finding("The document isn't tagged, so none of its content is");
}
for (p, n) in &scan.untagged {
out.push(Finding { page: Some(*p), message: format!("Page {}: {n} untagged content item{}", p + 1, if *n == 1 { "" } else { "s" }) });
// One item and several items are separate wordings, so a language can agree the
// noun with the number its own way.
let pattern = if *n == 1 { "Page {p}: {n} untagged content item" } else { "Page {p}: {n} untagged content items" };
out.push(Finding::worded(Some(*p), pattern, vec![("{p}", (p + 1).to_string()), ("{n}", n.to_string())]));
}
}
Rule::TaggedAnnotations | Rule::TaggedMultimedia | Rule::TaggedFormFields => {
@ -242,8 +264,19 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
_ => st != b"Widget" && !MULTIMEDIA.contains(&st) && st != b"PrinterMark",
};
if applies && !(tagged && a.contains(b"StructParent")) {
let what = if st == b"Widget" { "Form field".to_string() } else { format!("{} annotation", subtype_name(&a)) };
out.push(Finding { page: Some(p), message: format!("{what} on page {} isn't tagged", p + 1) });
// A whole sentence each way, rather than a noun put in front of one: a form
// field and an annotation of some subtype read differently in other
// languages. The subtype itself is the PDF's own name and goes in as it is.
let mut values = vec![("{p}", (p + 1).to_string())];
let pattern = if st == b"Widget" {
"Form field on page {p} isn't tagged"
} else {
values.push(("{}", subtype_name(&a)));
"{} annotation on page {p} isn't tagged"
};
let mut finding = Finding::worded(Some(p), pattern, values);
finding.bbox = annotation_rect(doc, &a);
out.push(finding);
}
}
}
@ -252,7 +285,7 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
for &p in list {
let Some(page) = pages.get(p) else { continue };
if !annots(page).is_empty() && page.dict.name(b"Tabs") != Some(b"S") {
out.push(Finding { page: Some(p), message: format!("Page {} doesn't tab in structure order", p + 1) });
out.push(Finding::worded(Some(p), "Page {p} doesn't tab in structure order", vec![("{p}", (p + 1).to_string())]));
}
}
}
@ -264,8 +297,18 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
&& let Some(f) = doc.get(*r).as_dict()
&& !maps_to_unicode(doc, f)
{
let name = f.name(b"BaseFont").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "(unnamed)".into());
out.push(Finding { page: Some(*p), message: format!("Font {name} on page {} doesn't map its characters to Unicode", p + 1) });
// A font with no name gets its own whole sentence, so another language can
// word that rather than translate a parenthesis. The name itself is the
// document's own and goes in as it stands.
let page = ("{p}", (p + 1).to_string());
out.push(match f.name(b"BaseFont") {
Some(n) => Finding::worded(
Some(*p),
"Font {} on page {p} doesn't map its characters to Unicode",
vec![("{}", String::from_utf8_lossy(n).into_owned()), page],
),
None => Finding::worded(Some(*p), "Font (unnamed) on page {p} doesn't map its characters to Unicode", vec![page]),
});
}
}
}
@ -286,10 +329,13 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
}
let fd = doc.get(field).as_dict().cloned().unwrap_or_default();
if text_of(doc, &fd, b"TU").is_none() {
out.push(Finding {
page: Some(p),
message: format!("Field \"{}\" on page {} has no description (tooltip)", full_name(doc, field), p + 1),
});
let mut finding = Finding::worded(
Some(p),
"Field \"{}\" on page {p} has no description (tooltip)",
vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())],
);
finding.bbox = annotation_rect(doc, &a);
out.push(finding);
}
}
}

234
crates/a11y/src/headings.rs Normal file
View File

@ -0,0 +1,234 @@
//! The document's headings (H, H1–H6 after the role map) with the text they mark: what New
//! Bookmarks from Structure turns into bookmarks.
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use pdfcraft_fonts::pdf::Metrics;
use pdfcraft_model::Page;
use crate::alt::{page_content, standard, tree_root};
const MAX_HEADINGS: usize = 10_000;
const MAX_VISITS: usize = 1_000_000;
const MAX_DEPTH: usize = 256;
/// Characters kept of a heading's title.
const MAX_TITLE: usize = 256;
/// A heading element.
#[derive(Clone, Debug, PartialEq)]
pub struct Heading {
/// The structure element (`None` for a direct element, which can't be referenced).
pub obj: Option<ObjRef>,
/// 1 for H1 … 6 for H6; a plain H takes the number of sections (Sect, Part, Art) around it.
pub level: u8,
pub title: String,
/// 0-based page of its content.
pub page: usize,
}
struct Walker<'a> {
doc: &'a Document,
role_map: Dict,
pages: &'a [Page],
page_index: HashMap<ObjRef, usize>,
seen: HashSet<ObjRef>,
visits: usize,
/// Per page: the text each MCID shows (read once per page).
texts: HashMap<usize, HashMap<i64, String>>,
out: Vec<Heading>,
}
/// The headings, in document order. Headings without text or a page are left out.
pub fn headings(doc: &Document) -> Vec<Heading> {
let Some(root) = tree_root(doc) else { return Vec::new() };
let pages = pdfcraft_model::pages(doc);
let mut w = Walker {
doc,
role_map: root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(),
pages: &pages,
page_index: pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(),
seen: HashSet::new(),
visits: 0,
texts: HashMap::new(),
out: Vec::new(),
};
if let Some(k) = root.get(b"K") {
w.walk(k, None, 0, 0);
}
w.out
}
impl Walker<'_> {
/// Whether `k` may be visited (bounded depth and size, each object once).
fn enter(&mut self, k: &Object, depth: usize) -> bool {
self.visits += 1;
depth <= MAX_DEPTH && self.visits <= MAX_VISITS && self.out.len() < MAX_HEADINGS && !matches!(k, Object::Ref(r) if !self.seen.insert(*r))
}
fn element_of(&self, k: &Object) -> Option<(Option<ObjRef>, Dict)> {
let d = match k {
Object::Ref(r) => self.doc.get(*r).as_dict().cloned()?,
Object::Dict(d) => d.clone(),
_ => return None,
};
d.contains(b"S").then(|| (k.as_ref(), d))
}
fn walk(&mut self, k: &Object, pg: Option<ObjRef>, sections: u8, depth: usize) {
if !self.enter(k, depth) {
return;
}
if let Object::Array(a) = k {
a.iter().for_each(|x| self.walk(x, pg, sections, depth + 1));
return;
}
let Some((obj, d)) = self.element_of(k) else { return };
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
let ty = d.name(b"S").map(|s| standard(self.doc, &self.role_map, s)).unwrap_or_default();
let level = match ty.as_slice() {
[b'H', n @ b'1'..=b'6'] => Some(n - b'0'),
b"H" => Some(sections.max(1)),
_ => None,
};
if let Some(level) = level {
self.heading(obj, &d, pg, level, depth);
return;
}
let sections = if matches!(ty.as_slice(), b"Sect" | b"Part" | b"Art") { sections.saturating_add(1) } else { sections };
if let Some(kids) = d.get(b"K") {
self.walk(kids, pg, sections, depth + 1);
}
}
/// The marked content under `k`: (page, MCID) pairs in order.
fn marks(&mut self, k: &Object, pg: Option<ObjRef>, depth: usize, out: &mut Vec<(Option<ObjRef>, i64)>) {
if !self.enter(k, depth) {
return;
}
match k {
Object::Int(n) => out.push((pg, *n)),
Object::Array(a) => a.iter().for_each(|x| self.marks(x, pg, depth + 1, out)),
Object::Dict(m) if m.name(b"Type") == Some(b"MCR") => {
if let Some(n) = m.get(b"MCID").and_then(Object::as_int) {
out.push((m.get(b"Pg").and_then(Object::as_ref).or(pg), n));
}
}
_ => {
if let Some((_, d)) = self.element_of(k)
&& let Some(kids) = d.get(b"K")
{
self.marks(kids, d.get(b"Pg").and_then(Object::as_ref).or(pg), depth + 1, out);
}
}
}
}
fn heading(&mut self, obj: Option<ObjRef>, d: &Dict, pg: Option<ObjRef>, level: u8, depth: usize) {
let mut marks = Vec::new();
if let Some(kids) = d.get(b"K") {
self.marks(kids, pg, depth + 1, &mut marks);
}
let Some(page) = pg.or_else(|| marks.iter().find_map(|m| m.0)).and_then(|p| self.page_index.get(&p).copied()) else { return };
let doc = self.doc;
let attr = |key: &[u8]| d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).filter(|t| !t.trim().is_empty());
let title = match attr(b"ActualText") {
Some(t) => Some(t),
None => self.marked_text(&marks, page).or_else(|| attr(b"Alt")).or_else(|| attr(b"T")),
};
let Some(title) = title else { return };
let title: String = title.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(MAX_TITLE).collect();
self.out.push(Heading { obj, level, title, page });
}
/// The text the marked content shows (`page`: where marks without a page of their own are).
fn marked_text(&mut self, marks: &[(Option<ObjRef>, i64)], page: usize) -> Option<String> {
let mut parts = Vec::new();
// The title is cut to MAX_TITLE characters later: stop gathering well before a heading
// that repeats one mark many times can pile up gigabytes.
let mut gathered = 0usize;
for (p, id) in marks {
if gathered >= MAX_TITLE * 4 {
break;
}
let page = p.and_then(|p| self.page_index.get(&p).copied()).unwrap_or(page);
if let Some(text) = self.texts_of(page).get(id) {
gathered = gathered.saturating_add(text.len()).saturating_add(1);
parts.push(text.clone());
}
}
Some(parts.join(" ")).filter(|t| !t.trim().is_empty())
}
fn texts_of(&mut self, page: usize) -> &HashMap<i64, String> {
let (doc, pages) = (self.doc, self.pages);
self.texts.entry(page).or_insert_with(|| pages.get(page).map(|p| mcid_texts(doc, &p.dict)).unwrap_or_default())
}
}
fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Dict {
d.get(key).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default()
}
/// The text each marked-content id shows on a page (text drawn directly in the page content).
fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
let res = sub(doc, page, b"Resources");
let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties"));
let mut metrics: HashMap<Vec<u8>, Metrics> = HashMap::new();
let mut font: Option<Vec<u8>> = None;
let mut stack: Vec<Option<i64>> = Vec::new();
let mut out: HashMap<i64, String> = HashMap::new();
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
let id = stack.iter().rev().find_map(|m| *m);
match op.op.as_slice() {
b"BMC" => stack.push(None),
b"BDC" => stack.push(match op.operands.get(1) {
Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int),
Some(Object::Name(n)) => {
props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int)))
}
_ => None,
}),
b"EMC" => {
stack.pop();
}
b"Tf" => {
font = op.name(0).map(<[u8]>::to_vec);
if let Some(name) = op.name(0)
&& !metrics.contains_key(name)
&& let Some(f) = fonts.get(name).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned())
{
metrics.insert(name.to_vec(), Metrics::from_dict(doc, &f));
}
}
_ => {}
}
let (Some(id), Some(m)) = (id, font.as_ref().and_then(|f| metrics.get(f))) else { continue };
let text = out.entry(id).or_default();
if text.len() >= MAX_TITLE * 4 {
continue;
}
match op.op.as_slice() {
b"Tj" | b"'" | b"\"" => {
if op.op != b"Tj" {
text.push(' ');
}
if let Some(s) = op.operands.iter().rev().find_map(Object::as_string) {
text.push_str(&m.decode(&s.bytes));
}
}
b"TJ" => {
for item in op.operands.first().and_then(Object::as_array).into_iter().flatten() {
match item {
Object::String(s) => text.push_str(&m.decode(&s.bytes)),
n if n.as_f64().is_some_and(|n| n < -200.0) => text.push(' '),
_ => {}
}
}
}
b"Td" | b"TD" | b"T*" | b"Tm" => text.push(' '),
_ => {}
}
}
out
}

View File

@ -8,17 +8,23 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::collections::BTreeSet;
use std::collections::{BTreeSet, HashMap};
use pdfcraft_cos::Document;
mod alt;
mod autotag;
mod content;
mod headings;
mod report;
mod structure;
mod tags;
pub use alt::{AltError, Figure, figures, mark_decorative, set_alt};
pub use report::report_html;
pub use autotag::{AutoTagError, AutoTagResult, autotag_text};
pub use headings::{Heading, headings};
pub use report::{REPORT_WORDS, report_html};
pub use tags::{TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tags};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Category {
@ -405,13 +411,158 @@ impl Status {
}
/// One problem: where it is and what is wrong.
///
/// The sentence comes in two forms. [`message`](Finding::message) is it, in English, ready to read.
/// [`pattern`](Finding::pattern) is the same sentence with a named hole where each value went, and
/// [`values`](Finding::values) is what filled them, so an interface can say it in another language
/// instead of showing the English one: it translates the pattern and puts the same values back.
/// `message` is always `pattern` with `values` in it, because [`Finding::worded`] builds it.
///
/// Every hole holds the document's own data — an element type, a font name, a count — and goes in
/// as it stands, with one exception: a hole named `{what}` holds another wording of ours, still with
/// its own holes in it. An interface translates that wording too and puts it in the frame first, and
/// then fills every hole in what it has; [`Finding::nested`] builds such a finding.
#[derive(Clone, Debug, PartialEq)]
pub struct Finding {
/// 0-based page, when the problem is on a page.
pub page: Option<usize>,
/// Indirect structure element that caused the finding, when one is available.
pub object: Option<pdfcraft_cos::ObjRef>,
/// Bounds in PDF user space when the affected content can be located safely.
pub bbox: Option<[f64; 4]>,
pub message: String,
/// The English sentence with a hole, such as `{p}` or `{what}`, where each value went.
pub pattern: &'static str,
/// What goes in each of `pattern`'s holes, by the hole's name (`"{p}"`, braces included).
pub values: Vec<(&'static str, String)>,
}
impl Finding {
/// A finding whose sentence says the same thing however the document is put together.
pub fn plain(page: Option<usize>, sentence: &'static str) -> Self {
Finding { page, object: None, bbox: None, message: sentence.to_owned(), pattern: sentence, values: Vec::new() }
}
/// A finding built from a wording and what goes in its holes. The English `message` is filled
/// in here, so it and `pattern` can never drift apart.
pub fn worded(page: Option<usize>, pattern: &'static str, values: Vec<(&'static str, String)>) -> Self {
Finding { page, object: None, bbox: None, message: say(pattern, &values), pattern, values }
}
/// A finding that says where the problem is and, in `{what}`, what is wrong, each worded
/// separately so the two read in whichever order the language reads them.
///
/// `what` is one of our own wordings and may have holes of its own; they are filled from
/// `values` along with the frame's. It goes in before the values do — it is ours, not the
/// document's — which is what lets its holes be filled at all.
pub fn nested(page: Option<usize>, pattern: &'static str, what: &'static str, mut values: Vec<(&'static str, String)>) -> Self {
let message = say(&pattern.replace("{what}", what), &values);
values.push(("{what}", what.to_owned()));
Finding { page, object: None, bbox: None, message, pattern, values }
}
/// This finding as one sentence, said in the language `words` translates into.
///
/// The frame is translated, the wording in `{what}` — if the frame has one — is translated and
/// goes in, and only then does the document's own data, in a single pass, so nothing the
/// document itself says can be read as a hole. With `&|s: &str| s.to_owned()` this is
/// [`message`](Finding::message).
pub fn said(&self, words: Words) -> String {
let mut frame = words(self.pattern);
if let Some((_, what)) = self.values.iter().find(|(name, _)| *name == "{what}") {
frame = frame.replace("{what}", &words(what));
}
say(&frame, &self.values)
}
}
/// How an interface says one of our wordings in its own language, holes and all: given the English
/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English.
pub type Words<'a> = &'a dyn Fn(&str) -> String;
/// The [`Words`] that translate nothing: the wording as it is written here.
pub fn in_english(wording: &str) -> String {
wording.to_owned()
}
/// A wording with its holes filled.
///
/// A hole is a name in braces, such as `{p}`; what is not in `values` is left as it is written.
/// Values go in during a single pass over the wording, so a value that itself contains braces is
/// never read as a hole: the document's own words reach the screen as they stand.
///
/// An interface says a [`Finding`] in its own language with this, after translating the pattern
/// and, if there is one, the wording in `{what}`.
pub fn say(pattern: &str, values: &[(&str, String)]) -> String {
let mut out = String::with_capacity(pattern.len() + 16);
let mut rest = pattern;
while let Some(open) = rest.find('{') {
let (before, from_brace) = rest.split_at(open);
out.push_str(before);
// `find` counts from the brace, and `}` is one byte, so this is a char boundary; `get`
// rather than slicing in case it ever isn't.
let Some(close) = from_brace.find('}') else {
// A brace with nothing closing it is just text.
out.push_str(from_brace);
return out;
};
let Some(hole) = from_brace.get(..=close) else { return out + from_brace };
match values.iter().find(|(name, _)| *name == hole) {
Some((_, value)) => out.push_str(value),
None => out.push_str(hole),
}
rest = from_brace.get(close + 1..).unwrap_or_default();
}
out.push_str(rest);
out
}
/// Every sentence the checker can say: each [`Finding::pattern`] and each wording that goes in a
/// `{what}` hole, in English, with its holes still in it.
///
/// An interface that shows the report in another language translates these, which is the whole
/// reason they are gathered here; `wordings_are_complete` fails if a rule says something that is
/// not on the list.
pub const WORDINGS: &[&str] = &[
// Document rules.
"The security settings don't allow screen readers to read the text",
"No page has text: the document appears to be images of pages",
"The document has no tags (structure tree)",
"The document isn't marked as tagged (MarkInfo Marked)",
"No document language is set",
"The document has no title",
"The window shows the file name, not the title (Initial View > Show)",
"{n} pages and no bookmarks",
// Page content and forms.
"The document isn't tagged, so none of its content is",
"Page {p}: {n} untagged content item",
"Page {p}: {n} untagged content items",
"Form field on page {p} isn't tagged",
"{} annotation on page {p} isn't tagged",
"Page {p} doesn't tab in structure order",
"Font {} on page {p} doesn't map its characters to Unicode",
"Font (unnamed) on page {p} doesn't map its characters to Unicode",
"Field \"{}\" on page {p} has no description (tooltip)",
// The structure rules: the two frames, then what goes in their `{what}`.
"{ty} element: {what}",
"{ty} element on page {p}: {what}",
"no alternate text",
"its alternate text is inside another element's and will never be read",
"alternate text on an element with no page content",
"alternate text hides an annotation inside it",
"not in a Table, THead, TBody or TFoot",
"not in a TR",
"no header cells (TH)",
"rows have between {min} and {max} columns",
"no summary",
"not in an L (list)",
"not in an LI (list item)",
"the first heading is an H{n}; headings should not skip levels",
"H{n} follows an H{d}; headings should not skip levels",
// The summary at the end of a long list.
AND_MORE,
];
#[derive(Clone, Debug, PartialEq)]
pub struct RuleResult {
pub rule: Rule,
@ -456,6 +607,9 @@ impl Report {
/// Findings beyond this many per rule are summarised ("… and n more").
const MAX_FINDINGS: usize = 200;
/// What the summary of the findings beyond [`MAX_FINDINGS`] says.
const AND_MORE: &str = "… and {n} more";
/// Run the full check.
pub fn check(doc: &Document, options: &Options) -> Report {
let pages = pdfcraft_model::pages(doc);
@ -463,8 +617,10 @@ pub fn check(doc: &Document, options: &Options) -> Report {
Some(p) => p.iter().copied().filter(|p| *p < pages.len()).collect(),
None => (0..pages.len()).collect(),
};
let tree = structure::Tree::read(doc, &pages);
let tagged = structure::has_struct_tree(doc);
let scan = content::Scan::run(doc, &pages, &page_list);
let mut tree_rules = structure::run(doc, &pages, &options.rules);
let mut figure_bounds: Option<HashMap<pdfcraft_cos::ObjRef, (usize, [f64; 4])>> = None;
let mut results = Vec::with_capacity(32);
for rule in Rule::ALL {
if !options.rules.contains(&rule) {
@ -475,15 +631,42 @@ pub fn check(doc: &Document, options: &Options) -> Report {
results.push(RuleResult { rule, status: Status::Manual, findings: Vec::new() });
continue;
}
let mut findings = match rule.category() {
Category::Document => content::document_rule(doc, rule, &pages, &scan),
Category::PageContent | Category::Forms => content::page_rule(doc, rule, &pages, &page_list, &scan, &tree),
_ => tree.rule(rule),
// `total` counts every finding; `findings` holds the first MAX_FINDINGS of them.
let (mut findings, total) = match rule.category() {
Category::Document => {
let found = content::document_rule(doc, rule, &pages, &scan);
let n = found.len();
(found, n)
}
Category::PageContent | Category::Forms => {
let found = content::page_rule(doc, rule, &pages, &page_list, &scan, tagged);
let n = found.len();
(found, n)
}
_ => tree_rules.remove(&rule).unwrap_or_default(),
};
if findings.len() > MAX_FINDINGS {
let more = findings.len() - MAX_FINDINGS;
if rule.category() == Category::AlternateText && findings.iter().any(|f| f.object.is_some()) {
let bounds = figure_bounds.get_or_insert_with(|| {
figures(doc)
.into_iter()
.filter_map(|figure| {
let (page, bbox) = (figure.page?, figure.bbox?);
(bbox.iter().all(|v| v.is_finite()) && bbox[0] < bbox[2] && bbox[1] < bbox[3]).then_some((figure.obj, (page, bbox)))
})
.collect()
});
for finding in &mut findings {
if let Some((page, bbox)) = finding.object.and_then(|obj| bounds.get(&obj))
&& finding.page == Some(*page)
{
finding.bbox = Some(*bbox);
}
}
}
if total > MAX_FINDINGS {
let more = total - MAX_FINDINGS;
findings.truncate(MAX_FINDINGS);
findings.push(Finding { page: None, message: format!("… and {more} more") });
findings.push(Finding::worded(None, AND_MORE, vec![("{n}", more.to_string())]));
}
let status = if findings.is_empty() { Status::Passed } else { Status::Failed };
results.push(RuleResult { rule, status, findings });

View File

@ -1,48 +1,80 @@
//! The accessibility report: a self-contained HTML page with the summary and, per category,
//! each rule's status, description and problems.
//!
//! The page is written in whichever language `words` translates into, down to its `lang` attribute,
//! so it reads the way the interface that saved it reads. Only the file name, the date and what the
//! findings say about the document itself come from elsewhere.
use crate::{Category, Report, Status};
use crate::{Category, Report, Status, Words};
fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
/// The report for `file_name`, dated `date` (as the caller formats it).
pub fn report_html(report: &Report, file_name: &str, date: &str) -> String {
/// The page's own words, in the order it says them: its heading, what it calls the file and the
/// date, the two things the summary can say, and the headers of the table.
///
/// The rest of what the page says it gets from elsewhere — the categories, the rules and the
/// findings name themselves — so an interface has these to translate and those to translate; a test
/// in this crate keeps the list in step with the page.
pub const REPORT_WORDS: &[&str] = &[
"Accessibility Report",
"Filename",
"Report created",
"Summary",
"The checker found problems which may prevent the document from being fully accessible.",
"The checker found no problems in this document.",
"Detailed Report",
"Rule Name",
"Status",
"Description",
];
/// The report for `file_name`, dated `date` (as the caller formats it), written in the language
/// `words` translates into and marked `lang` (`"en"` and `&|s: &str| s.to_owned()` for English).
pub fn report_html(report: &Report, file_name: &str, date: &str, lang: &str, words: Words) -> String {
let title = esc(&words("Accessibility Report"));
let mut h = String::new();
h.push_str("<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Accessibility Report</title>\n<style>\n");
h.push_str(&format!("<!doctype html>\n<html lang=\"{}\"><head><meta charset=\"utf-8\"><title>{title}</title>\n<style>\n", esc(lang)));
h.push_str(
"body{font:14px/1.45 system-ui,sans-serif;margin:24px;color:#1d1d1f}h1{font-size:22px}h2{font-size:17px;margin-top:28px}\
table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:6px 8px;text-align:left;vertical-align:top}\
th{background:#f2f2f2}.Failed{color:#b3261e;font-weight:600}.Passed{color:#1e7b34}ul{margin:4px 0 0;padding-left:18px}\n",
);
h.push_str("</style></head><body>\n<h1>Accessibility Report</h1>\n");
h.push_str(&format!("<p>Filename: <b>{}</b><br>Report created: {}</p>\n", esc(file_name), esc(date)));
h.push_str("<h2>Summary</h2>\n<p>");
h.push_str(if report.count(Status::Failed) > 0 {
"The checker found problems which may prevent the document from being fully accessible."
h.push_str(&format!("</style></head><body>\n<h1>{title}</h1>\n"));
h.push_str(&format!("<p>{}: <b>{}</b><br>{}: {}</p>\n", esc(&words("Filename")), esc(file_name), esc(&words("Report created")), esc(date)));
h.push_str(&format!("<h2>{}</h2>\n<p>", esc(&words("Summary"))));
// Each outcome is a whole sentence, and each is handed over on its own, so the list in
// `REPORT_WORDS` can be read off this file.
h.push_str(&if report.count(Status::Failed) > 0 {
esc(&words("The checker found problems which may prevent the document from being fully accessible."))
} else if report.count(Status::Manual) > 0 {
format!("{}: {}", esc(&words(Status::Manual.label())), report.count(Status::Manual))
} else if report.count(Status::Skipped) > 0 {
format!("{}: {}", esc(&words(Status::Skipped.label())), report.count(Status::Skipped))
} else {
"The checker found no problems in this document."
esc(&words("The checker found no problems in this document."))
});
h.push_str("</p>\n<ul>\n");
for s in [Status::Manual, Status::Skipped, Status::Passed, Status::Failed] {
h.push_str(&format!("<li>{}: {}</li>\n", s.label(), report.count(s)));
h.push_str(&format!("<li>{}: {}</li>\n", esc(&words(s.label())), report.count(s)));
}
h.push_str("</ul>\n<h2>Detailed Report</h2>\n");
h.push_str(&format!("</ul>\n<h2>{}</h2>\n", esc(&words("Detailed Report"))));
let (name, status, description) = (esc(&words("Rule Name")), esc(&words("Status")), esc(&words("Description")));
for c in Category::ALL {
h.push_str(&format!("<h3>{}</h3>\n<table><tr><th>Rule Name</th><th>Status</th><th>Description</th></tr>\n", c.label()));
h.push_str(&format!("<h3>{}</h3>\n<table><tr><th>{name}</th><th>{status}</th><th>{description}</th></tr>\n", esc(&words(c.label()))));
for r in report.in_category(c) {
h.push_str(&format!(
"<tr><td>{}</td><td class=\"{:?}\">{}</td><td>{}",
esc(r.rule.name()),
esc(&words(r.rule.name())),
r.status,
r.status.label(),
esc(r.rule.description())
esc(&words(r.status.label())),
esc(&words(r.rule.description()))
));
if !r.findings.is_empty() {
h.push_str("<ul>");
for f in &r.findings {
h.push_str(&format!("<li>{}</li>", esc(&f.message)));
h.push_str(&format!("<li>{}</li>", esc(&f.said(words))));
}
h.push_str("</ul>");
}

File diff suppressed because it is too large Load Diff

166
crates/a11y/src/tags.rs Normal file
View File

@ -0,0 +1,166 @@
//! Properties of reachable indirect structure elements.
use pdfcraft_cos::{CosError, Document, ObjRef, Object, PdfString};
use crate::alt;
/// A tag that can be addressed by an edit. Inline dictionaries have no stable object reference.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TagInfo {
pub object: ObjRef,
/// Nearest reachable indirect parent, if any. Inline ancestors are skipped.
pub parent: Option<ObjRef>,
/// Indentation level among reachable indirect tags.
pub depth: usize,
pub kind: String,
pub title: Option<String>,
pub actual_text: Option<String>,
pub alt: Option<String>,
pub language: Option<String>,
/// Zero-based page when `/Pg` is known.
pub page: Option<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TagProperty {
Type,
Title,
ActualText,
AlternateText,
Language,
}
/// Where to place a structure tag relative to one of its direct siblings.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TagPlacement {
Before,
After,
}
impl TagProperty {
pub fn from_id(id: &str) -> Option<Self> {
match id {
"type" => Some(Self::Type),
"title" => Some(Self::Title),
"actual_text" => Some(Self::ActualText),
"alternate_text" => Some(Self::AlternateText),
"language" => Some(Self::Language),
_ => None,
}
}
fn key(self) -> &'static [u8] {
match self {
Self::Type => b"S",
Self::Title => b"T",
Self::ActualText => b"ActualText",
Self::AlternateText => b"Alt",
Self::Language => b"Lang",
}
}
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum TagError {
#[error("object {0} is not a reachable indirect structure tag")]
NotATag(ObjRef),
#[error("a tag type must be a nonempty PDF name using letters, digits, colon, period, underscore or hyphen")]
InvalidType,
#[error("tag property text is too long (maximum 65536 bytes)")]
TooLong,
#[error("both tags must be distinct, reachable, direct children of the same structure parent")]
NotSiblings,
#[error("the parent must list each tag exactly once in its /K array")]
AmbiguousChildren,
#[error("the tag is already at the requested position")]
AlreadyThere,
#[error(transparent)]
Cos(#[from] CosError),
}
/// Reorder two direct sibling tags in their parent's `/K` array. Content and page appearance
/// stay untouched; tagged reading order follows the new array order.
pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: TagPlacement) -> Result<(), TagError> {
if object == anchor {
return Err(TagError::NotSiblings);
}
let reachable = alt::elements(doc);
if !reachable.iter().any(|e| e.object == object) || !reachable.iter().any(|e| e.object == anchor) {
return Err(TagError::NotSiblings);
}
let parent_of = |r| doc.get(r).as_dict().and_then(|d| d.get(b"P").and_then(Object::as_ref));
let Some(parent) = parent_of(object) else { return Err(TagError::NotSiblings) };
if parent_of(anchor) != Some(parent) {
return Err(TagError::NotSiblings);
}
let owner = doc.get(parent);
let Some(children) = owner.as_dict().and_then(|d| d.get(b"K").and_then(Object::as_array)) else {
return Err(TagError::AmbiguousChildren);
};
let one_position = |child| {
let mut matches = children.iter().enumerate().filter(|(_, item)| item.as_ref() == Some(child));
matches.next().and_then(|(index, _)| matches.next().is_none().then_some(index))
};
let (Some(from), Some(to)) = (one_position(object), one_position(anchor)) else { return Err(TagError::AmbiguousChildren) };
let mut reordered = children.clone();
let moved = reordered.remove(from);
let target = if from < to { to - 1 } else { to };
let destination = target + usize::from(placement == TagPlacement::After);
if destination == from {
return Err(TagError::AlreadyThere);
}
reordered.insert(destination, moved);
doc.update_dict(parent, |d| d.set(b"K".to_vec(), Object::Array(reordered)))?;
Ok(())
}
fn text(doc: &Document, d: &pdfcraft_cos::Dict, key: &[u8]) -> Option<String> {
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(PdfString::to_text))
}
/// List reachable, indirect tags in structure-tree order.
pub fn tags(doc: &Document) -> Vec<TagInfo> {
let pages = pdfcraft_model::pages(doc);
let page_index: std::collections::HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
alt::elements(doc)
.into_iter()
.filter_map(|e| {
Some(TagInfo {
object: e.object,
parent: e.parent,
depth: e.depth,
kind: String::from_utf8_lossy(e.dict.name(b"S")?).into_owned(),
title: text(doc, &e.dict, b"T"),
actual_text: text(doc, &e.dict, b"ActualText"),
alt: text(doc, &e.dict, b"Alt"),
language: text(doc, &e.dict, b"Lang"),
page: e.page.and_then(|p| page_index.get(&p).copied()),
})
})
.collect()
}
/// Change one property of a reachable tag; `None` removes a text property.
pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> {
if !alt::elements(doc).iter().any(|e| e.object == object) {
return Err(TagError::NotATag(object));
}
// ActualText may intentionally contain leading or trailing whitespace; preserve it.
let value = value.filter(|s| !s.is_empty());
if value.is_some_and(|s| s.len() > 65_536) {
return Err(TagError::TooLong);
}
if property == TagProperty::Type
&& !value.is_some_and(|s| s.len() <= 127 && s.as_bytes().iter().all(|b| b.is_ascii_alphanumeric() || matches!(b, b':' | b'.' | b'_' | b'-')))
{
return Err(TagError::InvalidType);
}
doc.update_dict(object, |d| match value {
Some(s) if property == TagProperty::Type => d.set(property.key().to_vec(), Object::name(s)),
Some(s) => d.set(property.key().to_vec(), Object::String(PdfString::text(s))),
None => {
d.remove(property.key());
}
})?;
Ok(())
}

View File

@ -1,6 +1,6 @@
use std::sync::Arc;
use pdfcraft_cos::Document;
use pdfcraft_cos::{Document, Object};
use super::*;
@ -35,6 +35,67 @@ fn failed(r: &Report) -> Vec<Rule> {
const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>";
#[test]
fn autotag_text_writes_marked_content_and_parent_tree_across_pages() {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream("BT /F1 18 Tf 20 160 Td (Heading) Tj ET BT /F1 12 Tf 20 120 Td (Body) Tj ET"),
FONT.into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 7 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream("BT /F1 12 Tf 20 120 Td (Second page) Tj ET"),
],
"",
);
let result = autotag_text(&mut doc).unwrap();
assert_eq!(result, AutoTagResult { pages: 2, paragraphs: 3 });
let catalog = doc.get(doc.root().unwrap());
let root = catalog.as_dict().unwrap().reference(b"StructTreeRoot").unwrap();
let tree = doc.get(root);
let parent_tree = tree.as_dict().unwrap().reference(b"ParentTree").unwrap();
let numbers = doc.get(parent_tree);
let entries = numbers.as_dict().unwrap().get(b"Nums").unwrap().as_array().unwrap();
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].as_int(), Some(0));
assert_eq!(entries[2].as_int(), Some(1));
assert_eq!(entries[1].as_array().unwrap().len(), 2);
assert_eq!(entries[3].as_array().unwrap().len(), 1);
for page in pdfcraft_model::pages(&doc) {
let content = page.dict.get(b"Contents").unwrap();
let stream = doc.resolve(content);
let Object::Stream(stream) = &*stream else { panic!("marked page content") };
let text = String::from_utf8(stream.decoded_strict().unwrap()).unwrap();
assert!(text.contains("/P <</MCID 0>> BDC"), "{text}");
assert!(text.contains("EMC"), "{text}");
}
let report = check(&doc, &Options::default());
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed);
assert_eq!(status(&report, Rule::TaggedContent), Status::Passed);
let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(tags(&reopened).len(), 4);
assert_eq!(status(&check(&reopened, &Options::default()), Rule::TaggedContent), Status::Passed);
}
#[test]
fn autotag_text_refuses_paint_and_does_not_modify_the_document() {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET 0 0 50 50 re f"),
FONT.into(),
],
"",
);
assert!(matches!(autotag_text(&mut doc), Err(AutoTagError::Unsupported { .. })));
assert!(!doc.get(doc.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot"));
assert_eq!(autotag_text(&mut pdf(&good(), "")), Err(AutoTagError::AlreadyTagged));
}
#[test]
fn an_untagged_document_fails_the_document_rules() {
let doc = pdf(
@ -157,6 +218,187 @@ fn row_and_column_spans_keep_a_table_regular() {
assert_eq!(status(&r, Rule::TableRegularity), Status::Passed, "{:?}", r.result(Rule::TableRegularity));
}
/// A tagged document whose structure tree is the Document element `document` (object 3).
fn structure_doc(objects: &[String]) -> Document {
let mut objs = vec!["<< /Type /Catalog /StructTreeRoot 2 0 R >>".to_string(), "<< /Type /StructTreeRoot /K 3 0 R >>".to_string()];
objs.extend_from_slice(objects);
pdf(&objs, "")
}
#[test]
fn reachable_tag_properties_round_trip_and_reject_unrelated_objects() {
let mut doc =
structure_doc(&["<< /S /Document /K [4 0 R] >>".into(), "<< /S /Figure /T (Old) /Alt (A chart) >>".into(), "<< /S /Figure >>".into()]);
let tag = pdfcraft_cos::ObjRef::new(4, 0);
let listed = tags(&doc);
assert_eq!(listed.len(), 2);
assert_eq!(listed[1].object, tag);
assert_eq!(listed[1].title.as_deref(), Some("Old"));
for (property, value) in [
(TagProperty::Type, "P"),
(TagProperty::Title, "Figure title"),
(TagProperty::ActualText, " Revenue increased "),
(TagProperty::AlternateText, "A rising chart"),
(TagProperty::Language, "en-GB"),
] {
set_tag_property(&mut doc, tag, property, Some(value)).unwrap();
}
let edited = &tags(&doc)[1];
assert_eq!(edited.kind, "P");
assert_eq!(edited.title.as_deref(), Some("Figure title"));
assert_eq!(edited.actual_text.as_deref(), Some(" Revenue increased "));
assert_eq!(edited.alt.as_deref(), Some("A rising chart"));
assert_eq!(edited.language.as_deref(), Some("en-GB"));
set_tag_property(&mut doc, tag, TagProperty::Title, None).unwrap();
assert_eq!(tags(&doc)[1].title, None);
assert!(matches!(set_tag_property(&mut doc, tag, TagProperty::Type, None), Err(TagError::InvalidType)));
assert!(matches!(set_tag_property(&mut doc, tag, TagProperty::Type, Some("bad type")), Err(TagError::InvalidType)));
assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(5, 0), TagProperty::Title, Some("No")), Err(TagError::NotATag(_))));
assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(4, 1), TagProperty::Title, Some("No")), Err(TagError::NotATag(_))));
}
#[test]
fn tag_hierarchy_keeps_indirect_parent_through_inline_tags() {
let doc = structure_doc(&["<< /S /Document /K 4 0 R >>".into(), "<< /S /Sect /K << /S /Div /K 5 0 R >> >>".into(), "<< /S /Figure >>".into()]);
let listed = tags(&doc);
assert_eq!(
listed.iter().map(|t| (t.object.num, t.parent.map(|p| p.num), t.depth)).collect::<Vec<_>>(),
[(3, None, 0), (4, Some(3), 1), (5, Some(4), 2)]
);
}
fn checked(doc: &Document, rules: &[Rule]) -> Report {
check(doc, &Options { rules: rules.iter().copied().collect(), pages: None })
}
#[test]
fn figures_and_tables_past_the_old_element_cut_off_are_checked() {
// Five thousand paragraphs, then a figure without alternate text and a table whose second row
// is narrower than its first.
let mut kids = vec!["<< /S /P >>".to_string(); 5_000];
kids.push("<< /S /Figure >>".into());
kids.push(
"<< /S /Table /A << /O /Table /Summary (s) >> /K [ << /S /TR /K [ << /S /TD >> << /S /TD >> ] >> << /S /TR /K [ << /S /TD >> ] >> ] >>"
.into(),
);
let doc = structure_doc(&[format!("<< /S /Document /K [{}] >>", kids.join(" "))]);
let r = checked(&doc, &[Rule::FiguresAltText, Rule::TableRegularity]);
assert_eq!(r.result(Rule::FiguresAltText).unwrap().findings.len(), 1);
assert!(r.result(Rule::TableRegularity).unwrap().findings[0].message.contains("between 1 and 2"));
}
#[test]
fn findings_are_listed_in_document_order_not_in_the_order_they_close() {
// Each element has alternate text but no content. The outer ones close last.
let doc = structure_doc(&["<< /S /Document /K [ << /S /Sect /Alt (a) /K [ << /S /P /Alt (b) /K [ << /S /Div /Alt (c) >> ] >> ] >> ] >>".into()]);
let r = checked(&doc, &[Rule::AltTextAssociated]);
let kinds: Vec<&str> = r.result(Rule::AltTextAssociated).unwrap().findings.iter().map(|f| f.message.split(' ').next().unwrap_or("")).collect();
assert_eq!(kinds, ["Sect", "P", "Div"]);
}
#[test]
fn a_rule_lists_its_first_findings_and_counts_the_rest() {
let figures = vec!["<< /S /Figure >>".to_string(); 300].join(" ");
let doc = structure_doc(&[format!("<< /S /Document /K [{figures}] >>")]);
let r = checked(&doc, &[Rule::FiguresAltText]);
let found = &r.result(Rule::FiguresAltText).unwrap().findings;
assert_eq!(found.len(), 201);
assert_eq!(found[200].message, "… and 100 more");
assert!(found[..200].iter().all(|f| f.message == "Figure element: no alternate text"));
}
#[test]
fn cycles_shared_kids_and_missing_kids_are_read_once() {
// Object 4 holds itself, a figure twice, and a missing object is listed too.
let doc = structure_doc(&[
"<< /S /Document /K [4 0 R 4 0 R 99 0 R] >>".into(),
"<< /S /Sect /K [4 0 R 5 0 R 5 0 R] >>".into(),
"<< /S /Figure >>".into(),
]);
let r = checked(&doc, &[Rule::FiguresAltText]);
assert_eq!(r.result(Rule::FiguresAltText).unwrap().findings.len(), 1);
}
#[test]
fn structure_findings_identify_indirect_elements_without_guessing_inline_objects() {
let doc = structure_doc(&["<< /S /Document /K [4 0 R << /S /Figure >>] >>".into(), "<< /S /Figure >>".into()]);
let r = checked(&doc, &[Rule::FiguresAltText]);
let found = &r.result(Rule::FiguresAltText).unwrap().findings;
assert_eq!(found.len(), 2);
assert_eq!(found[0].object.map(|r| r.num), Some(4));
assert_eq!(found[1].object, None);
}
#[test]
fn nesting_is_read_to_the_depth_limit_and_no_further() {
// Sections, each the kid of the one before, ending in a figure without alternate text. The
// figure is at depth `sections`; the walk reads depths up to 256.
let figure_within = |sections: usize| {
let mut objs: Vec<String> = (0..sections).map(|i| format!("<< /S /Sect /K {} 0 R >>", 4 + i)).collect();
objs.push("<< /S /Figure >>".into());
let r = checked(&structure_doc(&objs), &[Rule::FiguresAltText]);
r.result(Rule::FiguresAltText).unwrap().findings.len()
};
assert_eq!(figure_within(250), 1, "within the depth limit");
assert_eq!(figure_within(300), 0, "past the depth limit");
}
#[test]
fn arrays_nested_deeper_than_the_limit_are_not_read() {
let nested = |n: usize| format!("<< /S /Document /K {}<< /S /Figure >>{} >>", "[".repeat(n), "]".repeat(n));
let shallow = checked(&structure_doc(&[nested(100)]), &[Rule::FiguresAltText]);
assert_eq!(shallow.result(Rule::FiguresAltText).unwrap().findings.len(), 1);
let deep = checked(&structure_doc(&[nested(300)]), &[Rule::FiguresAltText]);
assert_eq!(deep.result(Rule::FiguresAltText).unwrap().findings.len(), 0);
}
#[test]
fn a_long_list_of_marked_content_counts_as_content() {
// The first paragraph holds 20,000 marked-content items, so its alternate text has content.
let items = (0..20_000).map(|i| i.to_string()).collect::<Vec<_>>().join(" ");
let doc = structure_doc(&[format!("<< /S /Document /K [ << /S /P /Alt (a) /K [{items}] >> << /S /P /Alt (b) >> ] >>")]);
let r = checked(&doc, &[Rule::AltTextAssociated]);
assert_eq!(r.result(Rule::AltTextAssociated).unwrap().findings.len(), 1);
}
#[test]
fn huge_spans_do_not_size_the_table() {
// A row of 2,000 cells, each spanning 10,000 columns, then a row of one cell.
let wide = vec!["<< /S /TD /A << /O /Table /ColSpan 10000 >> >>".to_string(); 2_000].join(" ");
let doc = structure_doc(&[format!(
"<< /S /Document /K [ << /S /Table /A << /O /Table /Summary (s) >> /K [ << /S /TR /K [{wide}] >> << /S /TR /K [ << /S /TD >> ] >> ] >> ] >>"
)]);
let r = checked(&doc, &[Rule::TableRegularity]);
let found = &r.result(Rule::TableRegularity).unwrap().findings;
assert_eq!(found[0].message, "Table element: rows have between 1 and 20000000 columns");
}
#[test]
fn row_spans_carry_past_their_row() {
// Row 1: 3,000 cells, each spanning 10,000 rows. Row 2: 3,000 cells, which start after them.
let row1 = vec!["<< /S /TD /A << /O /Table /RowSpan 10000 >> >>".to_string(); 3_000].join(" ");
let row2 = vec!["<< /S /TD >>".to_string(); 3_000].join(" ");
let doc = structure_doc(&[format!(
"<< /S /Document /K [ << /S /Table /A << /O /Table /Summary (s) >> /K [ << /S /TR /K [{row1}] >> << /S /TR /K [{row2}] >> ] >> ] >>"
)]);
let r = checked(&doc, &[Rule::TableRegularity]);
let found = &r.result(Rule::TableRegularity).unwrap().findings;
assert_eq!(found[0].message, "Table element: rows have between 3000 and 6000 columns");
}
#[test]
fn spans_that_are_not_positive_integers_read_as_one() {
// ColSpan -3 and 0 read as 1, and 2.0 reads as 2; the first row is 4 columns wide, the second 5.
let doc = structure_doc(&[
"<< /S /Document /K [ << /S /Table /A << /O /Table /Summary (s) >> /K [ \
<< /S /TR /K [ << /S /TD /A << /O /Table /ColSpan -3 >> >> << /S /TD /A << /O /Table /ColSpan 0 >> >> << /S /TD /A << /O /Table /ColSpan 2.0 >> >> ] >> \
<< /S /TR /K [ << /S /TD >> << /S /TD >> << /S /TD >> << /S /TD >> << /S /TD >> ] >> ] >> ] >>"
.into(),
]);
let r = checked(&doc, &[Rule::TableRegularity]);
assert_eq!(r.result(Rule::TableRegularity).unwrap().findings[0].message, "Table element: rows have between 4 and 5 columns");
}
#[test]
fn page_content_and_annotation_problems_are_found() {
let mut objs = good();
@ -171,6 +413,19 @@ fn page_content_and_annotation_problems_are_found() {
assert_eq!(got, [Rule::TaggedContent, Rule::TaggedAnnotations, Rule::TabOrder, Rule::FieldDescriptions]);
assert_eq!(r.result(Rule::TaggedContent).unwrap().findings[0].message, "Page 1: 1 untagged content item");
assert!(r.result(Rule::FieldDescriptions).unwrap().findings[0].message.contains("\"name\""));
assert_eq!(r.result(Rule::TaggedAnnotations).unwrap().findings[0].bbox, Some([20.0, 20.0, 80.0, 40.0]));
assert_eq!(r.result(Rule::FieldDescriptions).unwrap().findings[0].bbox, Some([20.0, 60.0, 120.0, 80.0]));
}
#[test]
fn annotation_findings_only_locate_valid_rectangles() {
let cases = [("[80 40 20 20]", Some([20.0, 20.0, 80.0, 40.0])), ("[20 20 20 40]", None), ("[20 20 80]", None), ("[20 20 80 (bad)]", None)];
for (rect, expected) in cases {
let mut objs = good();
objs[8] = format!("<< /Type /Annot /Subtype /Link /Rect {rect} >>");
let r = checked(&pdf(&objs, "/Info 8 0 R"), &[Rule::TaggedAnnotations]);
assert_eq!(r.result(Rule::TaggedAnnotations).unwrap().findings[0].bbox, expected, "{rect}");
}
}
#[test]
@ -218,9 +473,22 @@ fn rules_have_ids_and_the_report_lists_them() {
let per: Vec<usize> = Category::ALL.iter().map(|c| Rule::ALL.iter().filter(|r| r.category() == *c).count()).collect();
assert_eq!(per, [8, 9, 2, 5, 5, 2, 1]);
let doc = pdf(&good(), "/Info 8 0 R");
let html = report_html(&check(&doc, &Options::default()), "a<b>.pdf", "2026-10-02");
let report = check(&doc, &Options::default());
let html = report_html(&report, "a<b>.pdf", "2026-10-02", "en", &in_english);
assert!(html.contains("a&lt;b&gt;.pdf") && html.contains("Accessibility permission flag") && html.contains("Needs manual check: 5"));
assert!(html.contains("<h3>Headings</h3>"));
// In another language every word of ours is said in it, down to the `lang`; the file name is the
// file's own and stays as it is.
let other = report_html(&report, "a<b>.pdf", "2026-10-02", "es", &|w| format!("«{w}»"));
assert!(other.contains("<html lang=\"es\">") && other.contains("<h3>«Headings»</h3>") && other.contains("a&lt;b&gt;.pdf"));
assert!(!other.contains(">Headings<"), "an English heading is left in the report: {other}");
let manual_only = Report { results: vec![RuleResult { rule: Rule::LogicalReadingOrder, status: Status::Manual, findings: Vec::new() }] };
let manual_html = report_html(&manual_only, "review.pdf", "2026-10-02", "en", &in_english);
assert!(manual_html.contains("<p>Needs manual check: 1</p>"));
assert!(!manual_html.contains("no problems"));
let all_passed = Report { results: vec![RuleResult { rule: Rule::LogicalReadingOrder, status: Status::Passed, findings: Vec::new() }] };
let passed_html = report_html(&all_passed, "review.pdf", "2026-10-02", "en", &in_english);
assert!(passed_html.contains("<p>The checker found no problems in this document.</p>"), "{passed_html}");
}
fn figures_doc() -> Document {
@ -253,6 +521,49 @@ fn figures_are_listed_with_their_alt_text_and_place() {
assert_eq!(f.len(), 2, "role-mapped Photo counts");
assert_eq!((f[0].page, f[0].alt.as_deref(), f[0].bbox), (Some(0), None, Some([10.0, 150.0, 50.0, 170.0])));
assert_eq!((f[1].alt.as_deref(), f[1].bbox), (Some("A bar"), Some([100.0, 100.0, 150.0, 130.0])));
let report = check(&doc, &Options { rules: [Rule::FiguresAltText].into(), pages: None });
let finding = &report.result(Rule::FiguresAltText).unwrap().findings[0];
assert_eq!((finding.object, finding.page, finding.bbox), (Some(f[0].obj), Some(0), f[0].bbox));
}
#[test]
fn sibling_tags_move_in_reading_order_without_changing_content() {
let mut doc = figures_doc();
let before: Vec<_> = tags(&doc).into_iter().map(|tag| tag.object.num).collect();
assert_eq!(before, [6, 7, 8]);
let content = doc.get(pdfcraft_cos::ObjRef::new(4, 0));
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::Before).unwrap();
assert_eq!(tags(&doc).into_iter().map(|tag| tag.object.num).collect::<Vec<_>>(), [6, 8, 7]);
assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(4, 0)), content, "page content stays unchanged");
assert_eq!(
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::Before),
Err(TagError::AlreadyThere)
);
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::After).unwrap();
assert_eq!(tags(&doc).into_iter().map(|tag| tag.object.num).collect::<Vec<_>>(), [6, 7, 8]);
assert_eq!(
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(6, 0), TagPlacement::Before),
Err(TagError::NotSiblings)
);
assert_eq!(
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 1), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::Before),
Err(TagError::NotSiblings)
);
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
d.set(
b"K".to_vec(),
pdfcraft_cos::Object::Array(vec![
pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(7, 0)),
pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)),
pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(7, 0)),
]),
);
})
.unwrap();
assert_eq!(
move_tag(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), pdfcraft_cos::ObjRef::new(7, 0), TagPlacement::After),
Err(TagError::AmbiguousChildren)
);
}
#[test]
@ -279,3 +590,149 @@ fn setting_alt_text_and_marking_decorative() {
let text = String::from_utf8(c.decoded().unwrap()).unwrap();
assert!(text.contains("/Artifact BMC") && !text.contains("/MCID 0"), "{text}");
}
fn ref_(n: u32) -> Option<pdfcraft_cos::ObjRef> {
Some(pdfcraft_cos::ObjRef::new(n, 0))
}
/// Two tagged pages: role-mapped and nested headings, actual text, a ToUnicode font, a heading
/// without text and a heading that lists itself among its kids.
fn headings_doc() -> Vec<String> {
vec![
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 8 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 6 0 R /Resources << /Font << /F1 7 0 R /F2 9 0 R >> >> >>".into(),
stream(
"/H1 << /MCID 0 >> BDC BT /F1 12 Tf 20 180 Td (Chapter) Tj ( One) Tj ET EMC \
/P << /MCID 1 >> BDC BT (Body) Tj ET EMC \
/H << /MCID 2 >> BDC BT /F1 10 Tf [(Sec) -500 (A)] TJ ET EMC",
),
stream("/H2 << /MCID 0 >> BDC BT /F1 12 Tf (Ignored) Tj ET EMC /H3 << /MCID 1 >> BDC BT /F2 12 Tf <0102> Tj ET EMC"),
FONT.into(),
"<< /Type /StructTreeRoot /K 10 0 R /RoleMap << /Chapter /H1 >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Custom /ToUnicode 11 0 R >>".into(),
"<< /S /Document /K [12 0 R << /S /P /Pg 3 0 R /K 1 >> << /S /Sect /K << /S /Sect /K 13 0 R >> >> 14 0 R 15 0 R 16 0 R] >>".into(),
stream(
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap 1 begincodespacerange <00> <FF> endcodespacerange \
2 beginbfchar <01> <0041> <02> <0042> endbfchar endcmap end end",
),
"<< /S /Chapter /Pg 3 0 R /K 0 >>".into(),
"<< /S /H /Pg 3 0 R /K << /Type /MCR /MCID 2 >> >>".into(),
"<< /S /H2 /Pg 4 0 R /ActualText (Override) /K 0 >>".into(),
"<< /S /H3 /Pg 4 0 R /K [1 15 0 R] >>".into(),
"<< /S /H1 /Pg 4 0 R /K 9 >>".into(),
]
}
#[test]
fn headings_are_found_with_their_levels_titles_and_pages() {
let doc = pdf(&headings_doc(), "");
let found: Vec<_> = headings(&doc).into_iter().map(|h| (h.title, h.level, h.page, h.obj)).collect();
assert_eq!(
found,
[
("Chapter One".to_string(), 1, 0, ref_(12)),
("Sec A".to_string(), 2, 0, ref_(13)),
("Override".to_string(), 2, 1, ref_(14)),
("AB".to_string(), 3, 1, ref_(15)),
]
);
}
#[test]
fn an_untagged_document_has_no_headings() {
let mut objs = headings_doc();
objs[0] = "<< /Type /Catalog /Pages 2 0 R >>".into();
assert!(headings(&pdf(&objs, "")).is_empty());
}
/// The text of every `"…"` literal with a space in it, outside comments and byte strings.
fn sentences(source: &str) -> Vec<String> {
let b = source.as_bytes();
let (mut out, mut i) = (Vec::new(), 0);
while i < b.len() {
if b[i] == b'/' && b.get(i + 1) == Some(&b'/') {
while i < b.len() && b[i] != b'\n' {
i += 1;
}
} else if b[i] == b'"' {
let byte_string = i > 0 && b[i - 1] == b'b';
let start = i + 1;
i = start;
while i < b.len() && b[i] != b'"' {
i += if b[i] == b'\\' { 2 } else { 1 };
}
// Quotes and backslashes are ASCII, so this never cuts a character in two.
let text = source.get(start..i.min(b.len())).unwrap_or_default();
if !byte_string && text.contains(' ') {
out.push(text.replace("\\\"", "\""));
}
i += 1;
} else {
i += 1;
}
}
out
}
/// Every sentence the rules can say is listed in `WORDINGS`, so an interface showing the report in
/// another language has something to translate. The two rule files hold no prose of their own, so a
/// literal with a space in it is a sentence a person reads.
#[test]
fn wordings_are_complete() {
let mut missing = Vec::new();
for source in [include_str!("content.rs"), include_str!("structure.rs")] {
for sentence in sentences(source) {
if !WORDINGS.contains(&sentence.as_str()) {
missing.push(sentence);
}
}
}
assert!(missing.is_empty(), "add these to WORDINGS, and a translation of each to every interface: {missing:#?}");
}
/// Every word the report page says of its own is listed in `REPORT_WORDS`, and every word on that
/// list is one the page says: the page hands each over to `words` as a literal, so the two can be
/// read off one another.
#[test]
fn report_words_are_complete() {
let source = include_str!("report.rs");
let mut said = Vec::new();
let mut rest = source;
while let Some(at) = rest.find("words(\"") {
rest = &rest[at + 7..];
let Some(end) = rest.find('"') else { break };
said.push(&rest[..end]);
rest = &rest[end..];
}
let missing: Vec<&&str> = said.iter().filter(|word| !REPORT_WORDS.contains(word)).collect();
assert!(missing.is_empty(), "add these to REPORT_WORDS, and a translation of each to every interface: {missing:#?}");
let unsaid: Vec<&&str> = REPORT_WORDS.iter().filter(|word| !said.contains(word)).collect();
assert!(unsaid.is_empty(), "REPORT_WORDS lists words the page no longer says: {unsaid:#?}");
}
/// `say` puts the values in, and what it puts in is never read as a hole of its own: a document that
/// names a font `{p}` cannot make a finding say which page it is on.
#[test]
fn say_fills_holes_once() {
let values = [("{p}", "7".to_string()), ("{}", "{p} {n}".to_string())];
assert_eq!(
say("Font {} on page {p} doesn't map its characters to Unicode", &values),
"Font {p} {n} on page 7 doesn't map its characters to Unicode"
);
// A hole with nothing for it, and a brace with nothing closing it, are left as they stand.
assert_eq!(say("{a} {p} {", &values), "{a} 7 {");
}
/// A finding whose frame holds another wording says the whole thing in English, holes and all
/// filled, and still hands the interface the two wordings to translate.
#[test]
fn a_nested_finding_says_itself() {
let values = vec![("{min}", "1".to_string()), ("{max}", "2".to_string()), ("{ty}", "Table".to_string()), ("{p}", "3".to_string())];
let f = Finding::nested(Some(2), "{ty} element on page {p}: {what}", "rows have between {min} and {max} columns", values);
assert_eq!(f.message, "Table element on page 3: rows have between 1 and 2 columns");
assert_eq!(f.said(&in_english), f.message, "saying it in English is the message");
// Both wordings are translated, each on its own, and the document's words are left alone.
assert_eq!(f.said(&|w: &str| format!("«{w}»")), "«Table element on page 3: «rows have between 1 and 2 columns»»");
}

View File

@ -10,7 +10,14 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
- **Appearance streams** (`appearance::build`) generated from the annotation dictionary, so new
and restyled comments look the same in every viewer. Highlights multiply (`/BM /Multiply`).
Note icons are PdfCraft's own drawings. Text boxes use standard Helvetica (WinAnsi), with
line breaking by approximate Helvetica proportions (no font program is bundled).
line breaking by approximate Helvetica proportions (no font program is bundled). Text it can't
draw (in a text box, callout, Fill & Sign text, measurement label or dynamic stamp's by-line;
`appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with
the first such character (`pdfcraft_fonts::UNDRAWN_MARK`) so the engine reports the edit and
flattening refuses to make it permanent. Two refusals (`AnnotError::Undrawable`): a redaction
mark's overlay text, which becomes page content when applied; and a restyle or resize of a
comment another program drew with a font that has those characters, whose appearance is kept
rather than redrawn with `?` (new text for it is taken).
- **Edits:** reply (`/IRT`), set review status (a `/State` reply, as Acrobat's "Set status"
does), change text, move, resize, restyle, delete (with pop-up and replies).
@ -40,10 +47,10 @@ deterministic; the engine supplies them.
never paint a second icon.
- Resizing a stamp changes its `/Rect` and preserves its original appearance and resources;
viewers scale that appearance into the new rectangle. Locked stamps refuse the edit.
- Natural-size image stamps placed by the engine counterrotate their appearance on rotated
pages. Image restyling retains that appearance matrix. Explicit rectangles, image signatures
and PDF-page stamps still use their existing placement behavior; page-rotation support for
those paths is not complete.
- Picture stamps (a natural-size or explicit-rectangle image, a PDF page, or an image
signature) and PdfCraft's rubber stamps counterrotate their appearance on rotated pages, so
the picture or label reads upright as the page is displayed. Restyling a picture stamp keeps
that appearance matrix. The rotation is the page's `/Rotate` at placement.
## Not yet

View File

@ -11,8 +11,8 @@
//! Helvetica's proportions by character class (no font program or metrics file is bundled).
use pdfcraft_cos::{Dict, Object, PdfString, Stream};
use pdfcraft_fonts::{first_undrawable, literal, win_ansi};
pub use pdfcraft_fonts::{helvetica_width as text_width, wrap};
use pdfcraft_fonts::{literal, win_ansi};
use crate::{NOTE_SIZE, Rgb, n};
@ -104,7 +104,7 @@ fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f
/// point parallel to the chord between its neighbours (an end uses itself as the missing
/// neighbour). Ink is drawn so, as Acrobat draws it, rather than as straight segments. The curve
/// stays within its points and these control points.
pub(crate) fn smooth_segments(pts: &[(f64, f64)]) -> Vec<[(f64, f64); 3]> {
pub fn smooth_segments(pts: &[(f64, f64)]) -> Vec<[(f64, f64); 3]> {
let Some(&last) = pts.last() else { return Vec::new() };
let at = |i: usize| pts.get(i).copied().unwrap_or(last);
(0..pts.len().saturating_sub(1))
@ -170,6 +170,10 @@ fn color(d: &Dict, key: &[u8]) -> Option<Option<Rgb>> {
})
}
fn interior_color(d: &Dict, fallback: Option<Rgb>) -> Option<Rgb> {
color(d, b"IC").flatten().or(fallback)
}
fn border_width(d: &Dict) -> f64 {
if let Some(w) = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()) {
return w.max(0.0);
@ -260,7 +264,32 @@ pub(crate) fn picture_turn(d: &Dict) -> i64 {
d.get(b"PCPictureRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
}
/// The page `/Rotate` a rubber stamp's label is turned back by (`/PCStampRotate`).
fn stamp_turn(d: &Dict) -> i64 {
d.get(b"PCStampRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
}
/// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that
/// they can't, which would come out as `?`: what it is (for the message) and the character. The
/// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's
/// "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of
/// it can be drawn.
pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text);
let (what, text) = match d.name(b"Subtype")? {
b"FreeText" => ("the comment's text", text(b"Contents")?),
b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?),
b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?),
// Drawn in Helvetica when the redaction is applied: refused when it is marked.
b"Redact" => ("the redaction's overlay text", text(b"OverlayText")?),
_ => return None,
};
Some((what, first_undrawable(&text)?))
}
/// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported.
/// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and
/// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`).
pub fn build(d: &Dict) -> Option<Stream> {
// Imported measurement appearances may include leaders, captions and formatting that
// this builder cannot reproduce. Preserve them rather than silently losing detail.
@ -369,7 +398,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
}
}
b"Square" | b"Circle" => {
let fill = color(d, b"IC")?;
let fill = interior_color(d, None);
if stroke.is_none() && fill.is_none() {
return Some(form(rect, c.as_bytes(), res));
}
@ -403,7 +432,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) {
return None;
}
let fill = color(d, b"IC")?.unwrap_or(col);
let fill = interior_color(d, Some(col)).unwrap_or(col);
c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d)));
c.push_str(&format!("{} {} m {} {} l S\n[] 0 d\n", n(l[0]), n(l[1]), n(l[2]), n(l[3])));
for (end, (tip, from)) in ends.iter().zip([((l[0], l[1]), (l[2], l[3])), ((l[2], l[3]), (l[0], l[1]))]) {
@ -417,7 +446,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
let col = stroke;
let closed = subtype == b"Polygon";
let fill = if closed { color(d, b"IC")? } else { None };
let fill = if closed { interior_color(d, None) } else { None };
let ends = if subtype == b"PolyLine" {
match d.get(b"LE") {
None => None,
@ -461,7 +490,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None };
let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None };
let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None };
let fill = color(d, b"IC")?.unwrap_or(col);
let fill = interior_color(d, Some(col)).unwrap_or(col);
c.push_str(&format!("[] 0 d\n{}", rg(fill)));
if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) {
return None;
@ -608,7 +637,26 @@ pub fn build(d: &Dict) -> Option<Stream> {
}
let kind = crate::StampKind::from_name(other)?;
let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text);
return Some(stamp(kind, rect, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res));
let turn = stamp_turn(d);
let draw = if turn == 0 {
rect
} else {
let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]);
let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) };
if dw < 1.0 || dh < 1.0 {
return None;
}
[0.0, 0.0, dw, dh]
};
let mut stream = stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res);
if turn != 0 {
// Upright in the stamp's displayed box, then mapped into `/Rect` the way
// the page maps display space into user space. Viewers fit that box to
// `/Rect`; the page rotation then shows the label the right way up.
let m = pdfcraft_model::view_matrix_for(turn, rect);
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
}
return Some(stream);
}
}
}

View File

@ -14,7 +14,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
pub mod appearance;
pub mod links;
@ -26,7 +26,8 @@ mod tests;
pub enum AnnotError {
#[error("the document has no page tree")]
NoPageTree,
#[error("page {0} does not exist")]
/// Holds the 0-based index; the message names the 1-based page a user counts (#1550).
#[error("page {} does not exist", .0.saturating_add(1))]
NoSuchPage(usize),
#[error("there is no comment {index} on page {page}")]
NoSuchAnnotation { page: usize, index: usize },
@ -34,6 +35,11 @@ pub enum AnnotError {
Invalid(String),
#[error("{0} comments can't be restyled yet (their appearance can't be regenerated)")]
Unsupported(String),
/// Text the standard fonts can't draw where `?` would be permanent or replace a good drawing:
/// a redaction's overlay text, or an edit that would redraw a comment another program drew
/// with a font that has those characters. The message names the character.
#[error("{0}")]
Undrawable(String),
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
@ -741,6 +747,10 @@ pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool {
subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature")
}
fn annotation_flags(doc: &Document, d: &Dict) -> i64 {
d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0)
}
/// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden).
pub fn has_visible_fill_sign(doc: &Document) -> bool {
let Ok(pages) = page_refs(doc) else { return false };
@ -748,7 +758,7 @@ pub fn has_visible_fill_sign(doc: &Document) -> bool {
for entry in annots(doc, page) {
let obj = doc.resolve(&entry);
let Some(d) = obj.as_dict() else { continue };
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
let flags = annotation_flags(doc, d);
if flags & (FLAG_HIDDEN | FLAG_NO_VIEW) != 0 {
continue;
}
@ -1081,6 +1091,11 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
if let Some(b) = by {
d.set(b"PCByLine".to_vec(), PdfString::text(b));
}
// The label is drawn for the page as displayed. A later page rotation is a new edit.
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
if matches!(turn, 90 | 180 | 270) {
d.set(b"PCStampRotate".to_vec(), Object::Int(turn));
}
}
Shape::TypedSignature { contours, .. } => {
d.set(b"C".to_vec(), rgb(style.color));
@ -1198,8 +1213,16 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
// Survives a later subject edit, so flatten-on-save still finds the mark.
d.set(b"PCFillSign".to_vec(), Object::Bool(true));
}
// A redaction's overlay text becomes page content when it is applied, where `?` would be there
// for good: refused before anything is added. A comment's own text is stored as typed and
// drawn with `?` for what the standard fonts can't draw (marked, see `draw_appearance`).
if d.name(b"Subtype") == Some(b"Redact")
&& let Some((what, c)) = appearance::undrawable_text(&d)
{
return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c)));
}
let r = doc.add(Object::Dict(d.clone()));
set_appearance(doc, r)?;
draw_appearance(doc, r)?;
let mut list = annots(doc, page);
let index = list.len();
list.push(Object::Ref(r));
@ -1219,8 +1242,9 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
Ok(index)
}
/// Counterrotate a newly placed image stamp's appearance into displayed-page axes.
/// The normal appearance's transformed bounding box is fitted to `/Rect` by PDF viewers.
/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes.
/// The picture may be an image or a PDF page. The normal appearance's transformed bounding
/// box is fitted to `/Rect` by PDF viewers.
pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> {
let matrix = match rotation {
90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0],
@ -1230,8 +1254,8 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio
};
let (_, r) = annot_ref(doc, page, index)?;
let d = annot_dict(doc, r);
if d.name(b"Subtype") != Some(b"Stamp") || !matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
return Err(AnnotError::Invalid("only an image stamp can be oriented".into()));
if d.name(b"Subtype") != Some(b"Stamp") || d.get(b"PCPicture").is_none() {
return Err(AnnotError::Invalid("only a picture stamp can be oriented".into()));
}
let normal = d
.get(b"AP")
@ -1246,14 +1270,60 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio
Ok(())
}
/// (Re)generate `/AP /N` for the annotation `r` from its dictionary.
/// Regenerate an annotation's normal appearance from its dictionary.
/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would
/// replace those characters with `?`, so the edit is refused before the dictionary changes.
fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotError> {
let drawn_elsewhere =
d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(d).is_none();
if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(d) {
return Err(AnnotError::Undrawable(format!(
"PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)",
c.escape_debug(),
u32::from(c)
)));
}
Ok(())
}
/// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its
/// text (a restyle, a new size or colour).
///
/// A comment whose text the standard fonts can't draw, with an appearance another program drew
/// (with a font that has those characters), keeps that appearance: redrawing it would put `?` in
/// their place, so the edit is refused (`AnnotError::Undrawable`). One PdfCraft drew with `?`
/// (marked `pdfcraft_fonts::UNDRAWN_MARK`) is redrawn.
pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
refuse_foreign_text(doc, &annot_dict(doc, r))?;
draw_appearance(doc, r)
}
/// Put `before` back when redrawing fails, so a refused edit does not keep the new rectangle or style.
fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> Result<(), AnnotError> {
if let Err(e) = set_appearance(doc, r) {
doc.set(r, Object::Dict(before));
return Err(e);
}
Ok(())
}
/// Draw `r`'s normal appearance from its dictionary, whatever its text: characters the standard
/// fonts can't draw come out as `?`, and the annotation is marked (`pdfcraft_fonts::UNDRAWN_MARK`,
/// cleared when all of it can be drawn) so the edit is reported and flattening won't make that
/// `?` permanent. For new text (a new comment, changed contents), as Acrobat keeps text its font
/// can't show, and for display-only copies.
fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
let Some(mut stream) = appearance::build(&d) else { return Err(AnnotError::Unsupported(subtype)) };
// Image stamp restyling must retain the placement's page-axis correction.
if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) };
apply_appearance(doc, r, &d, stream)
}
/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`].
/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it.
fn build_appearance(doc: &Document, d: &Dict) -> Option<Stream> {
let mut stream = appearance::build(d)?;
// Picture-stamp restyling must retain the placement's page-axis correction.
if d.get(b"PCPicture").is_some() {
let matrix = d.get(b"AP").and_then(|ap| {
let ap = doc.resolve(ap);
let normal = doc.resolve(ap.as_dict()?.get(b"N")?);
@ -1264,6 +1334,13 @@ pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
stream.dict.set(b"Matrix".to_vec(), matrix);
}
}
Some(stream)
}
/// Store a built appearance on the annotation: the cheap half of [`set_appearance`].
fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> {
// Characters the standard fonts can't draw came out as `?`: mark the annotation with the first.
let missing = appearance::undrawable_text(d).map(|(_, c)| c);
let ap = doc.add(Object::Stream(stream));
// A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover
// appearances would show the previous look on press or hover, so they go with the old /N.
@ -1274,16 +1351,80 @@ pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
doc.update_dict(r, |d| {
d.set(b"AP".to_vec(), Object::Dict(apd));
d.remove(b"AS");
pdfcraft_fonts::set_undrawn_mark(d, missing);
})?;
Ok(())
}
/// Display appearances built for annotations without any, reused while the annotation
/// dictionaries they were drawn from are unchanged.
///
/// A content edit (moving a paragraph, retyping text, …) leaves every annotation dictionary
/// alone, but the display copy is rebuilt from the edited document on every refresh: without
/// this cache each refresh redrew every appearance (27k on one CAD file, ~0.7 s per edit
/// commit). Entries are keyed by annotation reference; object numbers are never reused within
/// a document lineage, so an entry stays valid until an annotation edit runs. Callers clear
/// the cache on any refresh that may change annotations (comments, structural and full
/// refreshes) and keep it for content-only ones.
#[derive(Clone, Debug, Default)]
pub struct AppearanceCache {
entries: std::collections::HashMap<ObjRef, Stream>,
bytes: usize,
hits: u64,
builds: u64,
}
impl AppearanceCache {
/// Cached appearance data restarts past this instead of growing without bound.
const MAX_BYTES: usize = 64 << 20;
/// Forget every entry (an annotation edit may have changed the dictionaries).
pub fn clear(&mut self) {
*self = Self::default();
}
fn get(&mut self, r: ObjRef) -> Option<Stream> {
let stream = self.entries.get(&r)?.clone();
self.hits += 1;
Some(stream)
}
fn insert(&mut self, r: ObjRef, stream: &Stream) {
self.builds += 1;
if self.entries.len() >= 1_000_000 {
self.clear();
}
if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES {
self.bytes += stream.raw.len();
self.entries.insert(r, stream.clone());
}
// Else over budget: keep serving the entries already cached instead of growing.
}
/// Cache hits so far (reused appearances); for tests.
pub fn hits(&self) -> u64 {
self.hits
}
/// Appearances built so far; for tests.
pub fn builds(&self) -> u64 {
self.builds
}
}
/// A copy of `doc` in which every annotation without a normal appearance (`/AP /N`) has the one
/// [`appearance::build`] draws from its dictionary, for displaying the document (`None` when no
/// annotation needs one). Many files carry comments without appearances (FreeText, Ink, notes,
/// stamps, …) that viewers draw from the dictionary. `doc` is not changed: saving writes the file
/// as it was. Links, form fields and pop-ups keep their own handling.
pub fn with_missing_appearances(doc: &Document) -> Option<Document> {
with_missing_appearances_cached(doc, &mut AppearanceCache::default())
}
/// A copy of `doc` with [`AppearanceCache`] reuse across refreshes: annotations whose
/// dictionary is unchanged since the cached build keep their stored appearance instead of
/// redrawing it. See [`AppearanceCache`] for when entries stay valid.
pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option<Document> {
let mut copy: Option<Document> = None;
for page in page_refs(doc).ok()? {
for r in annots(doc, page).iter().filter_map(Object::as_ref) {
@ -1292,11 +1433,18 @@ pub fn with_missing_appearances(doc: &Document) -> Option<Document> {
continue;
}
let has_normal = d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N")));
if has_normal || appearance::build(&d).is_none() {
if has_normal {
continue;
}
// Fails only when `r` isn't a dictionary, which `build` above has just ruled out.
set_appearance(copy.get_or_insert_with(|| doc.clone()), r).ok();
if let Some(stream) = cache.get(r) {
// Fails only when `r` isn't a dictionary, which the cached build ruled out.
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok();
continue;
}
let Some(stream) = build_appearance(doc, &d) else { continue };
cache.insert(r, &stream);
// Fails only when `r` isn't a dictionary, which the build just ruled out.
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok();
}
}
copy
@ -1403,10 +1551,12 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m
})?;
if free_text {
// The box grows and shrinks with the text instead of clipping it. A locked box keeps its
// text editable (as in Acrobat), so the re-fit ignores the lock.
// text editable (as in Acrobat), so the re-fit ignores the lock. New text is drawn even
// where the standard fonts can't draw some of it (marked and reported, see
// `draw_appearance`).
match fitted_box(doc, r, text) {
Some(rect) => apply_text_box(doc, r, rect, meta)?,
None => set_appearance(doc, r)?,
Some(rect) => apply_text_box(doc, r, rect, meta, true)?,
None => draw_appearance(doc, r)?,
}
}
Ok(())
@ -1504,7 +1654,7 @@ fn reply(
}
fn flags(doc: &Document, r: ObjRef) -> i64 {
doc.get(r).as_dict().and_then(|d| d.get(b"F").and_then(|f| doc.resolve(f).as_f64())).unwrap_or(0.0) as i64
doc.get(r).as_dict().map(|d| annotation_flags(doc, d)).unwrap_or(0)
}
/// Refuse to change a locked comment (Acrobat: Properties ▸ Locked).
@ -1589,8 +1739,9 @@ pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, d
Ok(())
}
/// Resize a rectangle, oval, text box or stamp to `rect`. Stamps keep their appearance,
/// which PDF viewers scale from its bounding box into the new rectangle.
/// Resize a rectangle, oval, text box, stamp or drawing to `rect`. Stamps keep their appearance,
/// which PDF viewers scale from its bounding box into the new rectangle; drawings scale their
/// strokes and get a new one.
pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
@ -1607,20 +1758,73 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
})?;
return Ok(());
}
if subtype == "Ink" {
return resize_ink(doc, r, normalize(rect), meta);
}
if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") {
return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized")));
}
apply_text_box(doc, r, rect, meta)
apply_text_box(doc, r, rect, meta, false)
}
/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half
/// the line width and a little more) stays the same on each side, so the line isn't clipped.
fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
let d = annot_dict(doc, r);
let strokes: Vec<Vec<[f64; 2]>> = d
.get(b"InkList")
.map(|l| doc.resolve(l))
.and_then(|l| l.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|s| {
let v: Vec<f64> = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default();
v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect()
})
.collect();
let Some(inner) = bounds(strokes.iter().flatten().copied()) else {
return Err(AnnotError::Invalid("the drawing has no strokes".into()));
};
let old = d.get(b"Rect").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
let old: Vec<f64> = old.iter().filter_map(|v| doc.resolve(v).as_f64()).collect();
let old = match old[..] {
[a, b, c, e] => normalize([a, b, c, e]),
_ => inner,
};
let margin = [inner[0] - old[0], inner[1] - old[1], old[2] - inner[2], old[3] - inner[3]].map(|m| m.max(0.0));
let target = [rect[0] + margin[0], rect[1] + margin[1], rect[2] - margin[2], rect[3] - margin[3]];
if !finite(&rect) || target[2] < target[0] || target[3] < target[1] || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
}
// An axis with no extent (a straight stroke) is centred rather than scaled.
let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| if a1 > a0 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { (b0 + b1) / 2.0 };
let scaled: Vec<Vec<[f64; 2]>> = strokes
.iter()
.map(|s| {
s.iter().map(|p| [axis(p[0], inner[0], inner[2], target[0], target[2]), axis(p[1], inner[1], inner[3], target[1], target[3])]).collect()
})
.collect();
let before = annot_dict(doc, r);
doc.update_dict(r, |d| {
d.set(b"InkList".to_vec(), Object::Array(scaled.iter().map(|s| num_array(&s.concat())).collect()));
d.set(b"Rect".to_vec(), num_array(&rect));
touch(d, meta);
})?;
redraw_or_restore(doc, r, before)
}
/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn.
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn
/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`).
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> {
let rect = normalize(rect);
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
}
let d = annot_dict(doc, r);
if !new_text {
refuse_foreign_text(doc, &d)?;
}
// A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it.
let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::<Vec<f64>>()).filter(|l| l.len() == 6);
let (outer, cl) = match callout {
@ -1646,7 +1850,16 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) ->
}
touch(d, meta);
})?;
set_appearance(doc, r)
let before = d;
if new_text {
if let Err(e) = draw_appearance(doc, r) {
doc.set(r, Object::Dict(before));
return Err(e);
}
Ok(())
} else {
redraw_or_restore(doc, r, before)
}
}
fn border_width_of(d: &Dict) -> f64 {
@ -1683,7 +1896,9 @@ pub fn set_style(
return Err(AnnotError::Invalid("this comment has no line endings to change".into()));
}
}
refuse_foreign_text(doc, &d)?;
let free_text = subtype == "FreeText";
let before = d;
doc.update_dict(r, |d| {
if let Some(c) = color {
if free_text {
@ -1721,7 +1936,44 @@ pub fn set_style(
ensure_ending_room(d);
touch(d, meta);
})?;
set_appearance(doc, r)
redraw_or_restore(doc, r, before)
}
/// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles,
/// ovals, polygons and clouds, §12.5.6.8–9).
pub fn fillable(subtype: &str) -> bool {
matches!(subtype, "Square" | "Circle" | "Polygon")
}
/// Fill a rectangle, oval or polygon comment with `fill`, or remove its fill (`None`), and
/// redraw its appearance (Acrobat: Properties ▸ Fill Color).
pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>, meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
if !fillable(&subtype) {
return Err(AnnotError::Invalid("only rectangles, ovals and polygons can be filled".into()));
}
// Check before changing anything: a stale appearance would contradict the new fill.
if appearance::build(&d).is_none() {
return Err(AnnotError::Unsupported(subtype));
}
if fill.is_some_and(|c| !finite(&c)) {
return Err(AnnotError::Invalid("invalid fill colour".into()));
}
refuse_foreign_text(doc, &d)?;
let before = d;
doc.update_dict(r, |d| {
match fill {
Some(c) => d.set(b"IC".to_vec(), rgb(c)),
None => {
d.remove(b"IC");
}
}
touch(d, meta);
})?;
redraw_or_restore(doc, r, before)
}
/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent.
@ -1780,6 +2032,8 @@ pub struct Summary {
pub quads: Vec<[f32; 8]>,
pub locked: bool,
pub intent: Option<String>,
/// A Fill & Sign mark ([`is_fill_sign`]).
pub fill_sign: bool,
}
fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
@ -1840,7 +2094,9 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
index,
subtype,
author: text_value(doc, d, b"T"),
contents: text_value(doc, d, b"Contents"),
// Acrobat ends lines with CR, which the interface would draw as nothing (keep in
// step with `pdfcraft_render::inspect`).
contents: text_value(doc, d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")),
modified: text_value(doc, d, b"M"),
name: text_value(doc, d, b"NM"),
in_reply_to,
@ -1848,8 +2104,9 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
color,
state: text_value(doc, d, b"State"),
quads,
locked: d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & FLAG_LOCKED != 0,
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
fill_sign: is_fill_sign(doc, d),
});
}
}
@ -1912,6 +2169,10 @@ pub struct Props {
pub locked: bool,
/// `/LE`: two names for a line or polyline (`None` when unset), one for a callout.
pub endings: Option<Vec<LineEnding>>,
/// Rectangles, ovals and polygons: their interior can be filled (see [`fillable`]).
pub fillable: bool,
/// `/IC`, the interior colour (`None`: no fill).
pub fill: Option<Rgb>,
}
/// The current properties of the comment at `(page, index)`.
@ -1934,12 +2195,26 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)),
modified: text_value(doc, d, b"M"),
restylable: appearance::build(d).is_some(),
locked: d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & FLAG_LOCKED != 0,
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
fillable: fillable(&subtype),
fill: fill_of(d),
subtype,
endings: line_endings_of(d),
})
}
/// `/IC` as an RGB colour. An empty array means no fill; grey and CMYK fills are converted.
fn fill_of(d: &Dict) -> Option<Rgb> {
let c: Vec<f64> = d.get(b"IC")?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
let rgb = match c.as_slice() {
[g] => [*g; 3],
[r, g, b] => [*r, *g, *b],
[c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)],
_ => return None,
};
finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0)))
}
/// `/LE` for a line, polyline or callout. Unknown names yield `None` so the control stays hidden.
fn line_endings_of(d: &Dict) -> Option<Vec<LineEnding>> {
let parse = |name: &[u8]| std::str::from_utf8(name).ok().and_then(LineEnding::parse);

View File

@ -202,13 +202,24 @@ fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), Anno
Ok(())
}
/// Check link geometry before writing PDF objects: a user or automation call can supply
/// non-finite coordinates or a finite pair whose difference overflows to infinity.
fn checked_rect(rect: [f64; 4]) -> Result<[f64; 4], AnnotError> {
if !rect.iter().all(|v| v.is_finite()) {
return Err(AnnotError::Invalid("the link area must have finite coordinates".into()));
}
let r = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
let (width, height) = (r[2] - r[0], r[3] - r[1]);
if !width.is_finite() || !height.is_finite() || width < 2.0 || height < 2.0 {
return Err(AnnotError::Invalid("the link area is too small or too large".into()));
}
Ok(r)
}
/// Add a link over `rect` (user space). Returns its index in the page's `/Annots`.
pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result<usize, AnnotError> {
let p = page_ref(doc, page)?;
let r = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < 2.0 || r[3] - r[1] < 2.0 {
return Err(AnnotError::Invalid("the link area is too small".into()));
}
let r = checked_rect(rect)?;
if matches!(action, LinkAction::Other(_)) {
return Err(AnnotError::Invalid("choose a page or a web address".into()));
}
@ -250,10 +261,7 @@ pub fn set(
let (_, r) = link_at(doc, page, index)?;
let mut d = doc.get(r).as_dict().cloned().unwrap_or_default();
if let Some(rect) = rect {
let rr = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
if rr[2] - rr[0] < 2.0 || rr[3] - rr[1] < 2.0 {
return Err(AnnotError::Invalid("the link area is too small".into()));
}
let rr = checked_rect(rect)?;
d.set(b"Rect".to_vec(), Object::Array(rr.iter().map(|v| Object::Real(*v)).collect()));
}
if let Some(s) = style {
@ -296,35 +304,95 @@ pub fn remove_all(doc: &mut Document, pages: Option<&[usize]>) -> Result<usize,
Ok(n)
}
/// Recognise common ASCII mailbox addresses without mistaking domains, partial words or
/// invalid labels for e-mail links. The returned end is in source-character indices.
fn email_at(chars: &[char], start: usize) -> Option<(usize, String)> {
let local_char = |c: char| c.is_ascii_alphanumeric() || "._+%-".contains(c);
let domain_char = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '.';
// Don't create a link starting in the middle of an address.
if start > 0 && chars.get(start - 1).is_some_and(|c| local_char(*c) || *c == '@') {
return None;
}
let mut at = start;
while chars.get(at).is_some_and(|c| local_char(*c)) {
at += 1;
if at - start > 64 {
return None;
}
}
let local = chars.get(start..at)?;
if local.is_empty() || local.first() == Some(&'.') || local.last() == Some(&'.') || local.windows(2).any(|w| w == ['.', '.']) {
return None;
}
if chars.get(at) != Some(&'@') {
return None;
}
let mut end = at + 1;
while chars.get(end).is_some_and(|c| domain_char(*c)) {
end += 1;
if end - at - 1 > 253 {
return None;
}
}
while end > at + 1 && chars.get(end - 1) == Some(&'.') {
end -= 1;
}
if chars.get(end).is_some_and(|c| c.is_ascii_alphanumeric() || *c == '@' || *c == '_') {
return None;
}
let domain = chars.get(at + 1..end)?;
let mut labels = 0;
let mut tld_length = 0;
for label in domain.split(|c| *c == '.') {
if label.is_empty() || label.len() > 63 || !label.first()?.is_ascii_alphanumeric() || !label.last()?.is_ascii_alphanumeric() {
return None;
}
labels += 1;
tld_length = label.len();
}
if labels < 2 || tld_length < 2 {
return None;
}
let address: String = chars.get(start..end)?.iter().collect();
Some((end, format!("mailto:{address}")))
}
/// Find web addresses in text: `http(s)://…`, `www.…` and bare e-mail addresses become
/// `mailto:`. Returns character ranges and the URI for each.
pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
let text: String = chars.iter().collect();
let lower = text.to_lowercase();
let lc: Vec<char> = lower.chars().collect();
let mut out = Vec::new();
let url_char = |c: char| !c.is_whitespace() && !"<>\"'()[]{}".contains(c);
// Case-insensitive prefix test that never steps past the end. Comparing against the original
// characters keeps every offset in `chars`'s own index space: case-folding a copy can change
// its length (U+0130 becomes "i" plus a combining dot), so a folded copy must never supply
// offsets into the source (that mismatch panicked; #816).
let starts_with =
|start: usize, pat: &str| pat.chars().enumerate().all(|(k, p)| chars.get(start + k).is_some_and(|c| c.eq_ignore_ascii_case(&p)));
let mut out = Vec::new();
let mut i = 0;
while i < lc.len() {
let at = |s: &str| lc[i..].iter().take(s.chars().count()).copied().eq(s.chars());
let boundary = i == 0 || !lc[i - 1].is_alphanumeric();
if boundary && (at("http://") || at("https://") || at("www.")) {
while i < chars.len() {
let boundary = i == 0 || chars.get(i - 1).is_some_and(|c| !c.is_alphanumeric());
if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || starts_with(i, "www.")) {
let mut j = i;
while j < chars.len() && url_char(chars[j]) {
while chars.get(j).is_some_and(|c| url_char(*c)) {
j += 1;
}
// Trailing punctuation belongs to the sentence.
while j > i && ".,;:!?".contains(chars[j - 1]) {
while j > i && chars.get(j - 1).is_some_and(|c| ".,;:!?".contains(*c)) {
j -= 1;
}
let s: String = chars[i..j].iter().collect();
let s: String = chars.get(i..j).map(|c| c.iter().collect()).unwrap_or_default();
if s.len() > 6 && s.contains('.') {
let uri = if s.to_lowercase().starts_with("www.") { format!("http://{s}") } else { s };
let uri = if s.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("www.")) { format!("http://{s}") } else { s };
out.push((i..j, uri));
i = j;
continue;
}
}
if boundary && let Some((end, uri)) = email_at(chars, i) {
out.push((i..end, uri));
i = end;
continue;
}
i += 1;
}
out

View File

@ -187,6 +187,7 @@ fn invalid_geometry_is_rejected() {
assert!(matches!(add_annotation(&mut doc, &new(0, s.clone()), &meta("x")), Err(AnnotError::Invalid(_))), "{s:?}");
}
assert_eq!(add_annotation(&mut doc, &new(5, Shape::Rectangle { rect: [0.0, 0.0, 10.0, 10.0] }), &meta("x")), Err(AnnotError::NoSuchPage(5)));
assert_eq!(AnnotError::NoSuchPage(5).to_string(), "page 6 does not exist", "the message counts pages from 1 (#1550)");
assert!(!doc.is_modified(), "failed adds change nothing");
}
@ -283,6 +284,50 @@ fn restyle_and_resize_redraw_the_appearance() {
assert!(matches!(set_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_))));
}
#[test]
fn shapes_can_be_filled_and_unfilled() {
let mut doc = fixture();
let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap();
let p = props(&doc, 0, r).unwrap();
assert!(p.fillable);
assert_eq!(p.fill, None);
set_fill(&mut doc, 0, r, Some([0.0, 0.5, 1.0]), &meta("")).unwrap();
let doc2 = reopen(&doc);
let d = &list(&doc2, 0)[r];
let ap = ap_content(&doc2, d);
assert!(ap.contains("0 0.5 1 rg") && ap.contains("\nB\n"), "filled and stroked: {ap}");
assert_eq!(props(&doc2, 0, r).unwrap().fill, Some([0.0, 0.5, 1.0]));
// No fill again: /IC goes and the appearance only strokes.
set_fill(&mut doc, 0, r, None, &meta("")).unwrap();
let doc2 = reopen(&doc);
let d = &list(&doc2, 0)[r];
assert!(d.get(b"IC").is_none());
let ap = ap_content(&doc2, d);
assert!(!ap.contains(" rg") && ap.contains("\nS\n"), "stroked only: {ap}");
assert_eq!(props(&doc2, 0, r).unwrap().fill, None);
// Ovals and polygons fill too.
let o = add_annotation(&mut doc, &new(0, Shape::Oval { rect: [10.0, 10.0, 60.0, 60.0] }), &meta("o")).unwrap();
set_fill(&mut doc, 0, o, Some([1.0, 1.0, 0.0]), &meta("")).unwrap();
assert!(ap_content(&doc, &list(&doc, 0)[o]).contains("1 1 0 rg"));
let vertices = vec![[0.0, 0.0], [50.0, 0.0], [25.0, 40.0]];
let g = add_annotation(&mut doc, &new(0, Shape::Polygon { vertices, cloud: false }), &meta("g")).unwrap();
set_fill(&mut doc, 0, g, Some([1.0, 0.0, 0.0]), &meta("")).unwrap();
assert!(ap_content(&doc, &list(&doc, 0)[g]).contains("1 0 0 rg"));
// Lines have no interior, and bad colours change nothing.
let l = add_annotation(
&mut doc,
&new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], start: LineEnding::None, end: LineEnding::None }),
&meta("l"),
)
.unwrap();
assert!(!props(&doc, 0, l).unwrap().fillable);
assert!(matches!(set_fill(&mut doc, 0, l, Some([1.0; 3]), &meta("")), Err(AnnotError::Invalid(_))));
let before = list(&doc, 0)[r].clone();
assert!(matches!(set_fill(&mut doc, 0, r, Some([f64::NAN, 0.0, 0.0]), &meta("")), Err(AnnotError::Invalid(_))));
assert_eq!(list(&doc, 0)[r], before);
assert!(set_fill(&mut doc, 0, 99, None, &meta("")).is_err());
}
#[test]
fn text_box_text_and_colour_changes_are_drawn() {
let mut doc = fixture();
@ -374,6 +419,33 @@ fn editing_a_callout_refits_the_box_and_keeps_the_leader() {
assert_eq!(ap_content(&doc, d).matches(" Tj").count(), appearance::wrap(long, 12.0, 200.0 - 2.0 * 4.0).len(), "nothing is clipped");
}
#[test]
fn real_annotation_flags_do_not_lock_comments() {
let mut doc = fixture();
let page = page_refs(&doc).unwrap()[0];
let mut entries = annots(&doc, page);
let Object::Dict(annotation) = &mut entries[0] else { panic!("fixture annotation is inline") };
annotation.set(b"F".to_vec(), Object::Real(128.5));
set_annots(&mut doc, page, entries).unwrap();
assert!(!summaries(&doc)[0].locked, "a non-integer /F is outside the annotation flag domain");
assert!(!props(&doc, 0, 0).unwrap().locked);
assert!(set_info(&mut doc, 0, 0, Some("Ada"), None, None, &meta("")).is_ok());
}
#[test]
fn carriage_returns_in_comment_text_become_line_breaks() {
let mut doc = fixture();
let page = page_refs(&doc).unwrap()[0];
let mut entries = annots(&doc, page);
let Object::Dict(annotation) = &mut entries[0] else { panic!("fixture annotation is inline") };
// Acrobat ends lines in /Contents with CR; the interface only breaks lines at LF.
annotation.set(b"Contents".to_vec(), PdfString::text("one\r\rtwo\r\nthree"));
set_annots(&mut doc, page, entries).unwrap();
assert_eq!(summaries(&doc)[0].contents.as_deref(), Some("one\n\ntwo\nthree"));
}
#[test]
fn a_locked_text_box_still_refits_its_text() {
let mut doc = fixture();
@ -516,6 +588,31 @@ fn links_are_added_edited_listed_and_removed() {
assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small");
}
#[test]
fn link_edit_rejects_non_finite_and_overflowed_rectangles_without_mutating_the_pdf() {
use crate::links::{self, LinkAction, LinkStyle};
let mut doc = fixture();
let original_rect = [10.0, 20.0, 110.0, 70.0];
let index = links::add(&mut doc, 0, original_rect, &LinkAction::Uri("https://example.org".into()), &LinkStyle::default()).unwrap();
let before = links::list(&doc);
let changed_action = LinkAction::Uri("https://other.example".into());
for rect in [
[f64::NAN, 0.0, 100.0, 30.0],
[0.0, 0.0, f64::INFINITY, 30.0],
[f64::NEG_INFINITY, 0.0, 100.0, 30.0],
[-1e308, 0.0, 1e308, 30.0],
[0.0, 0.0, 1.5, 30.0],
] {
assert!(links::set(&mut doc, 0, index, Some(rect), Some(&changed_action), None).is_err(), "edit accepted {rect:?}");
assert_eq!(links::list(&doc), before, "invalid edit changed an existing link");
assert!(links::add(&mut doc, 0, rect, &changed_action, &LinkStyle::default()).is_err(), "add accepted {rect:?}");
}
// A reversed but otherwise valid rectangle is normalized on edit.
links::set(&mut doc, 0, index, Some([110.0, 70.0, 10.0, 20.0]), None, None).unwrap();
let edited = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == index).map(|l| l.rect);
assert_eq!(edited, Some(original_rect));
}
#[test]
fn urls_are_found_in_text() {
let t: Vec<char> = "See https://example.org/a?b=1, or www.rust-lang.org. Not a.b or http:/x.".chars().collect();
@ -523,6 +620,55 @@ fn urls_are_found_in_text() {
assert_eq!(found, ["https://example.org/a?b=1", "http://www.rust-lang.org"]);
}
#[test]
fn common_email_addresses_become_mailto_links_with_source_character_ranges() {
let source = "İ Contact ada.lovelace+forms@example.co.uk, browse HTTPS://EXAMPLE.ORG; or ask admin@example.org.";
let chars: Vec<char> = source.chars().collect();
let links = crate::links::find_urls(&chars);
let found: Vec<(String, String)> = links.into_iter().map(|(range, url)| (chars.get(range).unwrap().iter().collect(), url)).collect();
assert_eq!(
found,
[
("ada.lovelace+forms@example.co.uk".into(), "mailto:ada.lovelace+forms@example.co.uk".into()),
("HTTPS://EXAMPLE.ORG".into(), "HTTPS://EXAMPLE.ORG".into()),
("admin@example.org".into(), "mailto:admin@example.org".into()),
]
);
}
#[test]
fn malformed_email_addresses_are_not_autolinked() {
for invalid in ["a@b", "broken@localhost", "a..b@example.org", "foo@.example.org", "foo@example..org", "foo@example.com@x"] {
let input: Vec<char> = invalid.chars().collect();
assert!(crate::links::find_urls(&input).is_empty(), "{invalid:?} produced a link");
}
}
#[test]
fn urls_are_found_after_length_changing_lowercase() {
// U+0130 (İ) lowercases to "i" plus a combining dot above, so a case-folded copy is longer
// than the source. The old matcher took the address offset from that copy and sliced the
// source with it, panicking at `chars[i..j]` (#816).
let mut text = "\u{130}".repeat(20);
text.push_str(" http://example.com");
let chars: Vec<char> = text.chars().collect();
let found = crate::links::find_urls(&chars);
assert_eq!(found.len(), 1, "{found:?}");
let (range, uri) = found.into_iter().next().unwrap();
assert_eq!(uri, "http://example.com");
assert_eq!(range, 21..39, "range is in source-character indices");
}
#[test]
fn url_scheme_and_www_match_case_insensitively() {
let t: Vec<char> = "HTTP://Example.COM".chars().collect();
let found: Vec<String> = crate::links::find_urls(&t).into_iter().map(|(_, u)| u).collect();
assert_eq!(found, ["HTTP://Example.COM"]);
let t: Vec<char> = "\u{130} www.Example.org".chars().collect();
let found: Vec<String> = crate::links::find_urls(&t).into_iter().map(|(_, u)| u).collect();
assert_eq!(found, ["http://www.Example.org"]);
}
#[test]
fn polygons_clouds_connected_lines_callouts_and_carets_are_drawn() {
let mut doc = fixture();
@ -773,6 +919,51 @@ fn line_dict(coords: [f64; 4], ending: &str) -> pdfcraft_cos::Dict {
d
}
#[test]
fn malformed_optional_interior_color_does_not_hide_shape_strokes() {
let mut square = pdfcraft_cos::Dict::new();
square.set(b"Subtype".to_vec(), Object::name("Square"));
square.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 40.0, 30.0].into_iter().map(Object::Real).collect()));
square.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].into_iter().map(Object::Real).collect()));
let malformed = Object::Array(vec![Object::Real(1.0), Object::Real(0.0)]);
square.set(b"IC".to_vec(), malformed.clone());
let mut circle = square.clone();
circle.set(b"Subtype".to_vec(), Object::name("Circle"));
let mut polygon = pdfcraft_cos::Dict::new();
polygon.set(b"Subtype".to_vec(), Object::name("Polygon"));
polygon.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 40.0, 30.0].into_iter().map(Object::Real).collect()));
polygon.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].into_iter().map(Object::Real).collect()));
polygon.set(b"Vertices".to_vec(), Object::Array([0.0, 0.0, 40.0, 0.0, 20.0, 30.0].into_iter().map(Object::Real).collect()));
polygon.set(b"IC".to_vec(), malformed.clone());
let mut line = line_dict([0.0, 0.0, 40.0, 30.0], "None");
line.set(b"IC".to_vec(), malformed.clone());
let mut polyline = pdfcraft_cos::Dict::new();
polyline.set(b"Subtype".to_vec(), Object::name("PolyLine"));
polyline.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 40.0, 30.0].into_iter().map(Object::Real).collect()));
polyline.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].into_iter().map(Object::Real).collect()));
polyline.set(b"Vertices".to_vec(), Object::Array([0.0, 0.0, 40.0, 0.0, 20.0, 30.0].into_iter().map(Object::Real).collect()));
polyline.set(b"LE".to_vec(), Object::Array(vec![Object::name("None"), Object::name("None")]));
polyline.set(b"IC".to_vec(), malformed);
for (name, annotation) in [("Square", square), ("Circle", circle), ("Polygon", polygon), ("Line", line), ("PolyLine", polyline)] {
assert!(appearance::build(&annotation).is_some(), "malformed optional /IC must not hide a valid {name} stroke");
}
let doc = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 50 40] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
b"<< /Type /Annot /Subtype /Square /Rect [5 5 45 35] /C [1 0 0] /IC [1 0] >>",
]);
let display = with_missing_appearances(&doc).expect("the valid square gets a fallback appearance");
let rendered = list(&display, 0).remove(0);
let content = ap_content(&display, &rendered);
assert!(content.contains("1 0 0 RG") && content.contains(" re\nS\n"), "the stroke remains drawable without a fill: {content}");
assert!(list(&doc, 0)[0].get(b"AP").is_none(), "display generation leaves the source document unchanged");
}
#[test]
fn every_line_ending_is_drawn() {
let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow];
@ -930,3 +1121,155 @@ fn non_markup_annotations_are_not_comments() {
assert!(is_comment_subtype(s), "{s}");
}
}
/// A drawing (a drawn Fill & Sign signature is one) resizes by scaling its strokes into the new
/// rectangle, keeping each side's stroke margin, and gets a fresh appearance.
#[test]
fn drawings_resize_by_scaling_their_strokes() {
let mut doc = fixture();
let strokes = vec![vec![[100.0, 400.0], [150.0, 460.0], [200.0, 410.0]], vec![[120.0, 420.0], [180.0, 430.0]]];
let i = add_annotation(&mut doc, &new(0, Shape::Ink { strokes }), &meta("i")).unwrap();
let nums = |o: &Object| -> Vec<f64> { o.as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect() };
let points = |doc: &Document| -> Vec<[f64; 2]> {
let d = &list(doc, 0)[i];
d.get(b"InkList").unwrap().as_array().unwrap().iter().flat_map(|s| nums(s).as_chunks::<2>().0.to_vec()).collect()
};
let before = points(&doc);
let r0 = nums(list(&doc, 0)[i].get(b"Rect").unwrap());
let lo = |k: usize| before.iter().map(|p| p[k]).fold(f64::MAX, f64::min);
let hi = |k: usize| before.iter().map(|p| p[k]).fold(f64::MIN, f64::max);
let margin = [lo(0) - r0[0], lo(1) - r0[1], r0[2] - hi(0), r0[3] - hi(1)];
assert!(margin.iter().all(|m| *m > 0.0), "{margin:?}");
// Twice the size, bottom-left corner fixed.
let rect = [r0[0], r0[1], r0[0] + 2.0 * (r0[2] - r0[0]), r0[1] + 2.0 * (r0[3] - r0[1])];
set_rect(&mut doc, 0, i, rect, &meta("r")).unwrap();
let d = &list(&doc, 0)[i];
let r1 = nums(d.get(b"Rect").unwrap());
assert!(r1.iter().zip(rect).all(|(a, b)| (a - b).abs() < 1e-6), "{r1:?} != {rect:?}");
// Each point keeps its place relative to the drawing's bounds, inside the same margin.
let inner = |r: &[f64]| [r[0] + margin[0], r[1] + margin[1], r[2] - margin[2], r[3] - margin[3]];
let (a, b) = (inner(&r0), inner(&r1));
for (p, q) in before.iter().zip(points(&doc)) {
let expected = [b[0] + (p[0] - a[0]) / (a[2] - a[0]) * (b[2] - b[0]), b[1] + (p[1] - a[1]) / (a[3] - a[1]) * (b[3] - b[1])];
assert!((q[0] - expected[0]).abs() < 1e-6 && (q[1] - expected[1]).abs() < 1e-6, "{q:?} != {expected:?}");
}
let ap = ap_content(&doc, d);
let first = points(&doc)[0];
assert!(ap.contains(&format!("{} {} m", n(first[0]), n(first[1]))), "appearance redrawn at the new size: {ap}");
// Too small to hold the stroke margin.
assert!(matches!(set_rect(&mut doc, 0, i, [100.0, 400.0, 101.0, 401.0], &meta("")), Err(AnnotError::Invalid(_))));
}
/// Comment text the standard fonts can't draw is kept, as Acrobat keeps it: a text box, Fill & Sign
/// text or a dynamic stamp's by-line is added with its text as typed, its appearance draws `?` for
/// those characters, and the comment is marked with the first one (cleared once all of it can be
/// drawn). A comment another program drew with a font that has those characters keeps that
/// appearance: a restyle is refused rather than replace it with `?`, while new text is taken.
#[test]
fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() {
let mut doc = fixture();
let mark = |doc: &Document, i: usize| pdfcraft_fonts::undrawn_mark(&list(doc, 0)[i]);
let mut boxed = new(0, Shape::TextBox { rect: [100.0, 600.0, 300.0, 650.0], font_size: 12.0 });
boxed.contents = "签名 Ada".into();
let t = add_annotation(&mut doc, &boxed, &meta("t")).unwrap();
assert_eq!(text(&list(&doc, 0)[t], b"Contents"), "签名 Ada");
assert!(ap_content(&doc, &list(&doc, 0)[t]).contains("(?? Ada)"), "{}", ap_content(&doc, &list(&doc, 0)[t]));
assert_eq!(mark(&doc, t), Some('签'));
let typed = Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 };
let f = add_annotation(
&mut doc,
&NewAnnotation { page: 0, style: Style::default_for(&typed), shape: typed, contents: "张三".into(), author: "Ada".into() },
&meta("x"),
)
.unwrap();
assert_eq!(mark(&doc, f), Some('张'));
let stamp =
Shape::Stamp { rect: [100.0, 100.0, 260.0, 142.0], stamp: StampKind::DynApproved, by: Some("By 张三 at 2:14 pm, Oct 02, 2026".into()) };
let s = add_annotation(
&mut doc,
&NewAnnotation { page: 0, style: Style::default_for(&stamp), shape: stamp, contents: String::new(), author: "张三".into() },
&meta("s"),
)
.unwrap();
assert_eq!(mark(&doc, s), Some('张'));
// Our own `?` appearance is redrawn on a restyle; Western European text clears the mark.
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
assert_eq!(mark(&doc, t), Some('签'));
set_contents(&mut doc, 0, t, "Zürich —\nGenève", &meta("")).unwrap();
assert_eq!(mark(&doc, t), None);
set_contents(&mut doc, 0, t, "签名", &meta("")).unwrap();
assert_eq!((text(&list(&doc, 0)[t], b"Contents").as_str(), mark(&doc, t)), ("签名", Some('签')));
// A text box another program drew (with a font that has 欢): a restyle keeps that appearance.
let mut foreign = Dict::new();
foreign.set(b"Type".to_vec(), Object::name("Annot"));
foreign.set(b"Subtype".to_vec(), Object::name("FreeText"));
foreign.set(b"Rect".to_vec(), Object::Array([100.0, 400.0, 300.0, 430.0].iter().map(|v| Object::Real(*v)).collect()));
foreign.set(b"DA".to_vec(), PdfString::literal(b"0 g /Helv 12 Tf".to_vec()));
foreign.set(b"Contents".to_vec(), PdfString::text("欢迎"));
let mut drawn = Dict::new();
drawn.set(b"Type".to_vec(), Object::name("XObject"));
drawn.set(b"Subtype".to_vec(), Object::name("Form"));
drawn.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, 200.0, 30.0].iter().map(|v| Object::Real(*v)).collect()));
let drawn = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(drawn, b"BT /F1 12 Tf (\x01\x02) Tj ET".to_vec())));
let mut ap = Dict::new();
ap.set(b"N".to_vec(), Object::Ref(drawn));
foreign.set(b"AP".to_vec(), Object::Dict(ap));
let r = doc.add(Object::Dict(foreign));
let page = page_ref(&doc, 0).unwrap();
let mut on_page = annots(&doc, page);
on_page.push(Object::Ref(r));
set_annots(&mut doc, page, on_page).unwrap();
let k = list(&doc, 0).len() - 1;
let before = list(&doc, 0)[k].clone();
let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None, &meta("")).unwrap_err();
assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}");
assert_eq!(list(&doc, 0)[k], before, "a refused restyle keeps the colour, opacity, width and appearance");
let err = set_rect(&mut doc, 0, k, [40.0, 40.0, 180.0, 90.0], &meta("")).unwrap_err();
assert!(matches!(&err, AnnotError::Undrawable(_)), "{err:?}");
assert_eq!(list(&doc, 0)[k], before, "a refused resize keeps the rectangle");
// New text from the user is taken (and marked), as for any comment.
set_contents(&mut doc, 0, k, "欢迎光临", &meta("")).unwrap();
assert_eq!(mark(&doc, k), Some('欢'));
// One without an appearance is shown in the display copy only; drawing it marks it.
let mut bare = Dict::new();
bare.set(b"Type".to_vec(), Object::name("Annot"));
bare.set(b"Subtype".to_vec(), Object::name("FreeText"));
bare.set(b"Rect".to_vec(), Object::Array([100.0, 300.0, 300.0, 330.0].iter().map(|v| Object::Real(*v)).collect()));
bare.set(b"DA".to_vec(), PdfString::literal(b"0 g /Helv 12 Tf".to_vec()));
bare.set(b"Contents".to_vec(), PdfString::text("欢迎"));
let b = doc.add(Object::Dict(bare));
let mut on_page = annots(&doc, page);
on_page.push(Object::Ref(b));
set_annots(&mut doc, page, on_page).unwrap();
let shown = with_missing_appearances(&doc).expect("a display copy");
assert!(shown.get(b).as_dict().unwrap().contains(b"AP"), "displayed anyway");
assert!(!doc.get(b).as_dict().unwrap().contains(b"AP"), "the document itself is unchanged");
set_appearance(&mut doc, b).unwrap();
assert_eq!(doc.get(b).as_dict().and_then(pdfcraft_fonts::undrawn_mark), Some('欢'));
// Kept through a save.
let doc = reopen(&doc);
assert_eq!(text(&list(&doc, 0)[t], b"Contents"), "签名");
}
/// Content edits rebuild the display copy on every commit but leave annotations alone: the
/// cached appearance is drawn once and reused after that, byte-identical.
#[test]
fn cached_appearances_are_reused_while_annotations_are_unchanged() {
let mut doc = fixture();
let idx = add_annotation(&mut doc, &new(0, Shape::Note { at: [100.0, 700.0], icon: NoteIcon::Comment }), &meta("cache")).expect("adds");
// Files carry comments without appearances; strip the created one to simulate that.
let page = page_refs(&doc).unwrap()[0];
let r = annots(&doc, page)[idx].as_ref().expect("indirect");
doc.update_dict(r, |d| {
d.remove(b"AP");
})
.expect("strips");
let mut cache = AppearanceCache::default();
let first = with_missing_appearances_cached(&doc, &mut cache).expect("builds");
assert_eq!((cache.builds(), cache.hits()), (1, 0), "only the note needs an appearance");
let second = with_missing_appearances_cached(&doc, &mut cache).expect("reuses");
assert_eq!((cache.builds(), cache.hits()), (1, 1), "nothing is redrawn");
let [a, b] = [&first, &second].map(|copy| ap_content(copy, &list(copy, 0)[idx]));
assert!(!a.is_empty());
assert_eq!(a, b, "the reused appearance is byte-identical");
}

20
crates/audit/Cargo.toml Normal file
View File

@ -0,0 +1,20 @@
[package]
name = "pdfcraft-audit"
description = "Verification tooling: audit documents for faux redactions (opaque covers over extractable text)"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
pdfcraft-cos = { workspace = true }
pdfcraft-content = { workspace = true }
pdfcraft-edit = { workspace = true }
pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]

31
crates/audit/README.md Normal file
View File

@ -0,0 +1,31 @@
# pdfcraft-audit
Layer L4 (may use `edit`): verification tooling, the faux-redaction audit.
```rust
let findings: Vec<Finding> = audit_redactions(&doc)?; // page, kind, rect, covered_text
```
It reports two kinds of finding:
- **faux redaction**: an opaque filled rectangle painted *after* text it overlaps, dark enough
against the text's colour (WCAG contrast below 3) to hide it, while the text is still
extractable underneath;
- **unapplied mark**: a Redact annotation that was marked but never applied.
A properly applied redaction removes the text, so it never flags. Page backgrounds, table
shading and highlights don't either: they're painted before the text, or light against it.
Limits, by design and documented rather than hidden:
- only filled rectangles count as covers, drawn with `re` or as a single `m`/`l` subpath whose
corners form an axis-aligned rectangle (not other paths, shadings or patterns), and a
translucent one (`/ca` below 1) is not flagged;
- covers inside form XObjects are found, but only text drawn directly in the page content is
checked under them;
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign.
It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper
than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings".
Surfaces: the `redact_audit` automation tool and `pdfcraft-cli`.

327
crates/audit/src/cover.rs Normal file
View File

@ -0,0 +1,327 @@
//! Walk page content streams and collect the opaque filled rectangles they draw.
//!
//! This is the geometric half of the faux-redaction audit: a rectangle filled with an opaque
//! color (`re` + `f`) is the classic way a "redaction" is faked. A single closed `m`/`l` subpath
//! whose four corners form an axis-aligned rectangle in user space is the same shape and counts
//! too (#1510). Graphics state tracked here is deliberately small: the CTM stack (`q`/`Q`/`cm`),
//! the non-stroking color (`g`/`rg`/`k`) and the non-stroking alpha from ExtGState (`gs` reading
//! `/ca`). Form XObjects are followed read-only with a depth cap. Anything fancier (other filled paths, shadings, patterns) is
//! out of scope and documented as such.
//!
//! Paint order: a page's content streams are one stream in pieces (ISO 32000-2 §7.8.2), so they
//! are parsed joined and walked once with a single graphics state — state set in an earlier
//! stream (a `cm`, a fill color) is still in force in the later ones. Each rectangle records the
//! position of the fill operator that painted it, as `(content-stream index, operator index in
//! the joined stream)`; rectangles inside a form XObject carry the `Do` operator's position.
//! Positions compare lexicographically in paint order.
use pdfcraft_content::{Matrix, Op, Pieces, parse};
use pdfcraft_cos::{Dict, Document, Object};
use crate::AuditError;
/// Maximum form-XObject nesting followed. Deeper nesting (including a cycle) fails the audit: a
/// cover drawn below the cut could otherwise go unseen and the page be reported clean.
const MAX_DEPTH: usize = 32;
/// Bound on total operators walked per page, so a hostile stream cannot spin forever.
/// Hitting it fails the audit: a truncated walk must never report "clean".
const MAX_OPS: usize = 2_000_000;
/// One opaque filled rectangle, in page user space.
#[derive(Clone, Debug, PartialEq)]
pub struct FilledRect {
/// `[x0, y0, x1, y1]`, normalized so `x0 <= x1` and `y0 <= y1`.
pub rect: [f64; 4],
/// The fill color as sRGB-ish `[r, g, b]` (CMYK is converted approximately).
pub rgb: [f64; 3],
/// Paint position: `(content-stream index, operator index in the joined stream)`.
/// Rectangles inside a form XObject carry the `Do` operator's position.
pub pos: (usize, usize),
}
#[derive(Clone)]
struct Gs {
ctm: Matrix,
fill: [f64; 3],
alpha: f64,
}
/// The path under construction, as far as the audit cares about it.
enum Path {
Empty,
/// A `re` rectangle, in user space.
Rect([f64; 4]),
/// One `m`/`l` subpath, corners already mapped to user space; `true` once closed by `h`.
Lines(Vec<[f64; 2]>, bool),
/// Anything else: curves, a second subpath, a CTM change mid-path.
Other,
}
impl Path {
/// The rectangle a fill of this path paints, if the path is one.
fn rect(self) -> Option<[f64; 4]> {
match self {
Path::Rect(r) => Some(r),
Path::Lines(pts, _) => lines_rect(pts),
Path::Empty | Path::Other => None,
}
}
}
/// The bounding box of a closed polygon that is an axis-aligned rectangle, or `None`. A repeated
/// start point is dropped first; the remaining four corners must alternate horizontal and
/// vertical edges.
fn lines_rect(mut pts: Vec<[f64; 2]>) -> Option<[f64; 4]> {
// CTM products leave rounding noise, so corners compare with a tolerance (in points).
const EPS: f64 = 1e-6;
let same = |a: f64, b: f64| (a - b).abs() <= EPS;
if pts.len() == 5 && same(pts[0][0], pts[4][0]) && same(pts[0][1], pts[4][1]) {
pts.pop();
}
let [a, b, c, d] = <[[f64; 2]; 4]>::try_from(pts).ok()?;
let fits = |first_horizontal: bool| {
[(a, b), (b, c), (c, d), (d, a)]
.iter()
.enumerate()
.all(|(i, (p, q))| if (i % 2 == 0) == first_horizontal { same(p[1], q[1]) } else { same(p[0], q[0]) })
};
if !(fits(true) || fits(false)) {
return None;
}
let (xs, ys) = ([a[0], b[0], c[0], d[0]], [a[1], b[1], c[1], d[1]]);
let r = [
xs.into_iter().fold(f64::INFINITY, f64::min),
ys.into_iter().fold(f64::INFINITY, f64::min),
xs.into_iter().fold(f64::NEG_INFINITY, f64::max),
ys.into_iter().fold(f64::NEG_INFINITY, f64::max),
];
(r[2] - r[0] > EPS && r[3] - r[1] > EPS).then_some(r)
}
impl Default for Gs {
fn default() -> Self {
Gs { ctm: Matrix::IDENTITY, fill: [0.0, 0.0, 0.0], alpha: 1.0 }
}
}
struct Walker<'a> {
doc: &'a Document,
pieces: &'a Pieces,
out: Vec<FilledRect>,
ops_seen: usize,
}
impl<'a> Walker<'a> {
fn bump(&mut self) -> Result<(), AuditError> {
self.ops_seen += 1;
if self.ops_seen > MAX_OPS {
return Err(AuditError::TooManyOps);
}
Ok(())
}
/// Walk one operator slice with the given graphics state. `at_do` is the `Do` position to
/// stamp on rectangles found inside a form XObject, or `None` at page level (where each
/// fill operator's own position is used).
fn walk_ops(
&mut self,
ops: &[Op],
gs: &mut Gs,
stack: &mut Vec<Gs>,
resources: &Dict,
depth: usize,
at_do: Option<(usize, usize)>,
) -> Result<(), AuditError> {
let mut path = Path::Empty;
for (i, op) in ops.iter().enumerate() {
self.bump()?;
// Paint position of this operator: its own `(piece, joined index)` at page level,
// the enclosing `Do`'s position inside a form XObject.
let pos = match at_do {
Some(p) => p,
None => (self.pieces.pieces_of(op).0, i),
};
match op.op.as_slice() {
b"q" => stack.push(gs.clone()),
b"Q" => {
if let Some(g) = stack.pop() {
*gs = g;
}
path = Path::Empty;
}
b"cm" => {
let before = gs.ctm;
if let Some(m) = Matrix::from_operands(&op.operands)
&& m.0.iter().all(|v| v.is_finite())
{
gs.ctm = m.then(&gs.ctm);
}
// A `cm` that leaves the CTM unchanged (an identity matrix) cannot move the
// pending path, so keep it for the fill (#1333).
if gs.ctm != before && !matches!(path, Path::Empty) {
path = Path::Other;
}
}
b"g" => {
if let Some(v) = op.num(0).filter(|v| v.is_finite()) {
let v = v.clamp(0.0, 1.0);
gs.fill = [v, v, v];
}
}
b"rg" => {
if let Some([r, g, b]) = op.nums::<3>()
&& [r, g, b].iter().all(|v| v.is_finite())
{
gs.fill = [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)];
}
}
b"k" => {
if let Some([c, m, y, k]) = op.nums::<4>()
&& [c, m, y, k].iter().all(|v| v.is_finite())
{
// Naive CMYK -> RGB; good enough to tell black from white.
let (c, m, y, k) = (c.clamp(0.0, 1.0), m.clamp(0.0, 1.0), y.clamp(0.0, 1.0), k.clamp(0.0, 1.0));
gs.fill = [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)];
}
}
b"gs" => {
if let Some(name) = op.name(0) {
gs.alpha = ext_alpha(self.doc, resources, name);
}
}
b"re" => {
let rect = op.nums::<4>().and_then(|[x, y, w, h]| {
if [x, y, w, h].iter().all(|v| v.is_finite()) && w != 0.0 && h != 0.0 {
let r = gs.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]);
(r[2] > r[0] && r[3] > r[1]).then_some(r)
} else {
None
}
});
path = rect.map_or(Path::Other, Path::Rect);
}
b"m" => {
path = match (path, user_point(gs, op)) {
(Path::Empty, Some(p)) => Path::Lines(vec![p], false),
_ => Path::Other,
};
}
b"l" => {
path = match (path, user_point(gs, op)) {
// Past a repeated start point it can no longer be a rectangle.
(Path::Lines(mut pts, false), Some(p)) if pts.len() < 5 => {
pts.push(p);
Path::Lines(pts, false)
}
_ => Path::Other,
};
}
b"h" => {
path = match path {
Path::Lines(pts, _) => Path::Lines(pts, true),
_ => Path::Other,
};
}
b"f" | b"F" | b"f*" | b"B" | b"B*" => {
if let Some(r) = std::mem::replace(&mut path, Path::Empty).rect()
&& gs.alpha >= 1.0
{
self.out.push(FilledRect { rect: r, rgb: gs.fill, pos });
}
}
b"S" | b"s" | b"n" => path = Path::Empty,
b"c" | b"v" | b"y" | b"W" | b"W*" => path = Path::Other,
b"Do" => {
if let Some(name) = op.name(0) {
if depth >= MAX_DEPTH {
return Err(AuditError::TooDeep);
}
self.walk_xobject(name, gs, resources, depth, pos)?;
}
path = Path::Empty;
}
_ => {}
}
}
Ok(())
}
/// Follow a form XObject read-only, composing its matrix onto the current CTM. State
/// changes inside never leak out; every rectangle found is stamped with the `Do`
/// position `pos`.
fn walk_xobject(&mut self, name: &[u8], gs: &Gs, resources: &Dict, depth: usize, pos: (usize, usize)) -> Result<(), AuditError> {
let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let Some(entry) = xobjects.get(name) else { return Ok(()) };
let resolved = self.doc.resolve(entry);
let Object::Stream(stream) = &*resolved else { return Ok(()) };
let dict = &stream.dict;
if dict.name(b"Subtype") != Some(b"Form") {
return Ok(());
}
let matrix = dict
.get(b"Matrix")
.map(|o| self.doc.resolve(o))
.and_then(|o| o.as_array().map(|a| a.to_vec()))
.and_then(|a| {
let v: Vec<f64> = a.iter().filter_map(|o| self.doc.resolve(o).as_f64()).collect();
(v.len() == 6 && v.iter().all(|x| x.is_finite())).then(|| Matrix([v[0], v[1], v[2], v[3], v[4], v[5]]))
})
.unwrap_or(Matrix::IDENTITY);
let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
// A form stream that cannot be decoded fails the audit: it might hide a cover.
let data = stream.decoded_strict()?;
let ops = parse(&data).ops;
// The XObject matrix maps form space into the user space active at `Do`.
let mut sub_gs = gs.clone();
sub_gs.ctm = matrix.then(&gs.ctm);
let mut sub_stack = Vec::new();
self.walk_ops(&ops, &mut sub_gs, &mut sub_stack, &sub_resources, depth + 1, Some(pos))
}
}
/// The `x y` operands of `m`/`l`, mapped to user space.
fn user_point(gs: &Gs, op: &Op) -> Option<[f64; 2]> {
let [x, y] = op.nums::<2>().filter(|v| v.iter().all(|n| n.is_finite()))?;
let (x, y) = gs.ctm.apply(x, y);
Some([x, y])
}
/// The non-stroking alpha (`/ca`) of a named ExtGState, defaulting to opaque.
fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 {
let alpha = resources
.get(b"ExtGState")
.map(|o| doc.resolve(o))
.and_then(|o| o.as_dict().cloned())
.and_then(|d| d.get(name).map(|o| doc.resolve(o)))
.and_then(|o| o.as_dict().cloned())
.and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()));
alpha.filter(|a| a.is_finite()).map(|a| a.clamp(0.0, 1.0)).unwrap_or(1.0)
}
/// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint
/// positions. A page's content streams are walked once, joined, with one graphics state, so
/// state set in an earlier stream is still in force in the later ones. Any content stream that
/// cannot be decoded is an audit failure, never an empty result.
pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, AuditError> {
let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let contents: Vec<Object> = match page.get(b"Contents") {
None => Vec::new(),
Some(c) => match &*doc.resolve(c) {
Object::Array(a) => a.clone(),
_ => vec![c.clone()],
},
};
let mut streams = Vec::with_capacity(contents.len());
for c in &contents {
if let Object::Stream(s) = &*doc.resolve(c) {
streams.push(s.decoded_strict()?);
}
}
let pieces = Pieces::join(&streams);
let parsed = pieces.parse();
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0 };
let mut gs = Gs::default();
let mut stack = Vec::new();
w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?;
Ok(w.out)
}

192
crates/audit/src/lib.rs Normal file
View File

@ -0,0 +1,192 @@
//! Verification tooling: audit a document for faux redactions.
//!
//! [`audit_redactions`] finds *faux redactions* — opaque filled rectangles drawn over text
//! that is still extractable underneath — and redaction annotations that were marked but
//! never applied. A properly applied redaction removes the text, so it never flags.
//!
//! A rectangle counts as a cover only when it was painted strictly *after* the text it
//! overlaps, and only when it is dark enough against the text's color to make the text
//! unreadable (WCAG contrast below 3.0). Page backgrounds, table shading and highlight
//! marks are therefore not flagged.
//!
//! Detection limits (documented, not hidden): only `re` rectangles closed by a fill operator
//! are considered — arbitrary filled paths, shadings and patterns are not; a rectangle drawn
//! with a translucent ExtGState (`/ca < 1`) is not flagged; annotation rectangles are compared
//! in unrotated user space, so pages with `/Rotate` may misalign; covers inside form XObjects
//! are found, but only text drawn directly in the page content is checked under them, so text
//! inside a form XObject is not audited.
//!
//! The audit fails closed: any content that cannot be read (an undecodable content stream,
//! malformed annotations, form XObjects nested deeper than 32 or in a cycle) is an error, never
//! an empty finding list.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_cos::{Dict, Document};
use pdfcraft_edit::TextLine;
mod cover;
#[cfg(test)]
mod tests;
/// What the audit found.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FindingKind {
/// An opaque filled rectangle covers text that is still extractable.
CoveredText,
/// A `/Redact` annotation was placed but never applied: the text is untouched.
UnappliedMark,
}
impl FindingKind {
pub fn id(self) -> &'static str {
match self {
FindingKind::CoveredText => "covered-text",
FindingKind::UnappliedMark => "unapplied-mark",
}
}
}
/// One audit finding.
#[derive(Clone, Debug, PartialEq)]
pub struct AuditFinding {
/// 0-based page index.
pub page: usize,
pub kind: FindingKind,
/// The covering rectangle (or the mark's rectangle), in page user space.
pub rect: [f64; 4],
/// The extractable text the cover hides (possibly empty when nothing decodable remains).
pub covered_text: String,
}
#[derive(Debug, thiserror::Error)]
pub enum AuditError {
#[error("page {0}: {1}")]
Text(usize, String),
#[error("content walk exceeded the operator bound")]
TooManyOps,
#[error("form XObjects nest deeper than the audit follows")]
TooDeep,
#[error("page has a malformed /Annots entry")]
BadAnnots,
#[error(transparent)]
Cos(#[from] pdfcraft_cos::CosError),
#[error(transparent)]
Edit(#[from] pdfcraft_edit::EditError),
}
/// Fraction of a text line's area that must lie under a cover to count as hidden.
const COVER_FRACTION: f64 = 0.4;
/// WCAG contrast ratio below which the text is unreadable against the cover.
const COVER_CONTRAST: f64 = 3.0;
/// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any
/// content that cannot be decoded is returned as an error, never as an empty finding list.
pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError> {
let mut out = Vec::new();
for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() {
let lines = pdfcraft_edit::text_lines(doc, n).map_err(|e| AuditError::Text(n, e.to_string()))?;
// 1. Opaque filled rectangles over extractable text.
for r in cover::filled_rects(doc, &page.dict)? {
let mut covered = String::new();
for line in &lines {
if is_cover(&r, line) {
if !covered.is_empty() {
covered.push(' ');
}
covered.push_str(line.text.trim());
}
}
if !covered.trim().is_empty() {
out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered });
}
}
// 2. Redact annotations that were never applied.
for annot in annots(doc, &page.dict)? {
let mut covered = String::new();
for line in &lines {
if covers(&annot, &line.rect) {
if !covered.is_empty() {
covered.push(' ');
}
covered.push_str(line.text.trim());
}
}
if !covered.trim().is_empty() {
out.push(AuditFinding { page: n, kind: FindingKind::UnappliedMark, rect: annot, covered_text: covered });
}
}
}
Ok(out)
}
/// A filled rectangle is a faux-redaction cover for a text line iff it was painted strictly
/// after the line, overlaps it substantially, and is dark enough against the line's color to
/// make the text unreadable. Light covers (table shading, highlights) over dark text fail the
/// contrast check and are not flagged.
fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool {
rect.pos > line.paint_pos() && covers(&rect.rect, &line.rect) && contrast(line.color, rect.rgb) < COVER_CONTRAST
}
/// Does `cover` hide a substantial part of `line`?
fn covers(cover: &[f64; 4], line: &[f64; 4]) -> bool {
let ix0 = cover[0].max(line[0]);
let iy0 = cover[1].max(line[1]);
let ix1 = cover[2].min(line[2]);
let iy1 = cover[3].min(line[3]);
if ix1 <= ix0 || iy1 <= iy0 {
return false;
}
let line_area = (line[2] - line[0]).max(0.0) * (line[3] - line[1]).max(0.0);
if line_area <= 0.0 {
return false;
}
(ix1 - ix0) * (iy1 - iy0) / line_area >= COVER_FRACTION
}
/// WCAG 2.x relative luminance of an sRGB triple.
fn luminance(rgb: [f64; 3]) -> f64 {
let lin = rgb.map(|c| {
let c = c.clamp(0.0, 1.0);
if c <= 0.03928 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
});
0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
}
/// WCAG contrast ratio between two sRGB colors: 1.0 (identical) to 21.0 (black on white).
fn contrast(a: [f64; 3], b: [f64; 3]) -> f64 {
let (l1, l2) = (luminance(a), luminance(b));
let (hi, lo) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
(hi + 0.05) / (lo + 0.05)
}
/// Rectangles of `/Redact` annotations on a page (unrotated user space). A missing `/Annots`
/// means no marks; a malformed one fails the audit rather than silently passing.
fn annots(doc: &Document, page: &Dict) -> Result<Vec<[f64; 4]>, AuditError> {
let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) };
let resolved = doc.resolve(a);
let items: Vec<pdfcraft_cos::Object> = match &*resolved {
pdfcraft_cos::Object::Array(items) => items.clone(),
// A missing /Annots means no marks; a malformed one fails the audit.
_ => return Err(AuditError::BadAnnots),
};
let mut out = Vec::new();
for a in &items {
let dict = match &*doc.resolve(a) {
pdfcraft_cos::Object::Dict(d) => d.clone(),
_ => continue,
};
if dict.name(b"Subtype") != Some(b"Redact") {
continue;
}
let v: Vec<f64> = match dict.get(b"Rect") {
None => continue,
Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(),
};
if v.len() == 4 && v.iter().all(|x| x.is_finite()) {
out.push([v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]);
}
}
Ok(out)
}

274
crates/audit/src/tests.rs Normal file
View File

@ -0,0 +1,274 @@
//! Tests build their own tiny PDFs in code (contributor-original fixtures): no binary files,
//! no Adobe material, exact xref offsets computed by the builder below.
use std::sync::Arc;
use pdfcraft_cos::Document;
use crate::{FindingKind, audit_redactions};
/// Minimal PDF assembler: objects are numbered 1..=n in order.
struct Builder {
objects: Vec<Vec<u8>>,
}
impl Builder {
fn new() -> Self {
Builder { objects: Vec::new() }
}
fn add(&mut self, body: &str) -> usize {
self.objects.push(body.as_bytes().to_vec());
self.objects.len()
}
fn add_stream(&mut self, data: &[u8]) -> usize {
self.add_stream_with(data, "")
}
fn add_stream_with(&mut self, data: &[u8], dict_extra: &str) -> usize {
let mut body = format!("<< /Length {} {dict_extra} >>\nstream\n", data.len()).into_bytes();
body.extend_from_slice(data);
body.extend_from_slice(b"\nendstream");
self.objects.push(body);
self.objects.len()
}
fn build(self) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, obj) in self.objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(obj);
out.extend_from_slice(b"\nendobj\n");
}
let xref_at = out.len();
let n = self.objects.len() + 1;
out.extend_from_slice(format!("xref\n0 {n}\n").as_bytes());
out.extend_from_slice(b"0000000000 65535 f \n");
for off in offsets {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(b"trailer\n<< ");
out.extend_from_slice(format!("/Size {n} /Root 1 0 R").as_bytes());
out.extend_from_slice(b" >>\nstartxref\n");
out.extend_from_slice(format!("{xref_at}\n").as_bytes());
out.extend_from_slice(b"%%EOF\n");
out
}
}
const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>";
/// One page with `content` as its stream; `page_extra` is appended inside the page dict.
fn one_page(content: &[u8], page_extra: &str) -> Vec<u8> {
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
let stream_num = 4;
b.add(&format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {stream_num} 0 R /Resources << /Font << /F1 5 0 R >> >> {page_extra} >>"
));
b.add_stream(content);
b.add(FONT);
b.build()
}
/// One page with two content streams (`/Contents [4 0 R 5 0 R]`).
fn two_streams(s1: &[u8], s2: &[u8]) -> Vec<u8> {
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents [4 0 R 5 0 R] /Resources << /Font << /F1 6 0 R >> >> >>");
b.add_stream(s1);
b.add_stream(s2);
b.add(FONT);
b.build()
}
/// One page whose content stream claims `/FlateDecode` but holds garbage: decoding must fail.
fn bad_filter_page() -> Vec<u8> {
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>");
b.add_stream_with(b"this is not deflated data", "/Filter /FlateDecode");
b.add(FONT);
b.build()
}
fn open(bytes: Vec<u8>) -> Document {
Document::open(Arc::new(bytes)).expect("test pdf must open")
}
#[test]
fn faux_redaction_is_found() {
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n";
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
let f = &findings[0];
assert_eq!(f.page, 0);
assert_eq!(f.kind, FindingKind::CoveredText);
assert!(f.covered_text.contains("SECRET"), "covered_text was {:?}", f.covered_text);
}
#[test]
fn identity_matrix_between_rectangle_and_fill_still_covers_text() {
// A no-op `cm` inside the path must not drop the pending rectangle (#1333).
for cover in [&b"0 g\n100 688 220 32 re 1 0 0 1 0 0 cm f\n"[..], b"0 g\n1 0 0 1 0 0 cm 100 688 220 32 re f\n"] {
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec();
content.extend_from_slice(cover);
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{:?}: {findings:?}", String::from_utf8_lossy(cover));
assert_eq!(findings[0].kind, FindingKind::CoveredText);
assert_eq!(findings[0].rect, [100.0, 688.0, 320.0, 720.0]);
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
}
}
#[test]
fn clean_document_has_no_findings() {
let content = b"BT /F1 24 Tf 100 700 Td (nothing hidden here) Tj ET\n";
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn black_box_without_text_is_not_a_finding() {
// A filled rectangle with no text under it is decoration, not a faux redaction.
let content = b"BT /F1 24 Tf 100 100 Td (far away) Tj ET\n0 g\n400 600 100 40 re f\n";
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn translucent_cover_is_not_flagged() {
// A rectangle drawn with a translucent ExtGState does not hide the text.
let content = b"/GS1 gs\nBT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n";
let page_extra = "/Resources << /Font << /F1 5 0 R >> /ExtGState << /GS1 6 0 R >> >>";
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add(&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R {page_extra} >>"));
b.add_stream(content);
b.add(FONT);
b.add("<< /Type /ExtGState /ca 0.5 >>");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn unapplied_redact_mark_is_found() {
let content = b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n";
let page_extra = "/Annots [6 0 R]";
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add(&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> {page_extra} >>"));
b.add_stream(content);
b.add(FONT);
b.add("<< /Type /Annot /Subtype /Redact /Rect [95 685 305 725] /C [1 0 0] >>");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].kind, FindingKind::UnappliedMark);
assert!(findings[0].covered_text.contains("DO NOT SHARE"));
}
#[test]
fn background_before_text_is_not_flagged() {
// An opaque dark rectangle painted *before* the text is a background, not a cover.
let content = b"0 g\n100 688 220 32 re f\nBT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn light_shading_after_text_is_not_flagged() {
// Light table-style shading painted after the text leaves it readable: not a cover.
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0.9 g\n100 688 220 32 re f\n";
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn graphics_state_carries_across_streams() {
// The first stream sets a white fill after the text; the second stream's rectangle over
// the text is therefore white, not the default black: no finding. Without the carry fix
// the rectangle would default to black and flag.
let s1 = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n1 g\n";
let s2 = b"100 688 220 32 re f\n";
let findings = audit_redactions(&open(two_streams(s1, s2))).expect("audit must run");
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn audit_fails_closed_on_undecodable_stream() {
// A content stream that cannot be decoded is an audit error, never an empty finding list.
let err = audit_redactions(&open(bad_filter_page())).expect_err("audit must fail closed");
let msg = err.to_string();
assert!(msg.contains("decoded"), "unexpected error: {msg}");
}
/// A form XObject that draws itself nests without end. The walk can't follow it to the bottom, so
/// the audit fails rather than report the page clean with a possible cover left unseen.
#[test]
fn self_drawing_form_fails_the_audit() {
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 6 0 R >> /XObject << /X 5 0 R >> >> >>");
b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n/X Do\n");
b.add_stream_with(
b"0 g\n100 688 220 32 re f\n/X Do\n",
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /XObject << /X 5 0 R >> >>",
);
b.add(FONT);
let result = audit_redactions(&open(b.build()));
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
}
#[test]
fn rectangle_drawn_as_a_path_covers_text_like_re() {
// The same cover as `re`, built from `m`/`l` (closed with `h`, a repeated start point, or
// only by the fill itself), or in a scaled user space: all are faux redactions (#1510).
let covers: [&[u8]; 5] = [
b"0 g\n100 688 220 32 re f\n",
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h f\n",
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l 100 688 l h f\n",
b"0 g\n100 688 m 100 720 l 320 720 l 320 688 l f\n",
b"0 g\nq 2 0 0 2 0 0 cm 50 344 m 160 344 l 160 360 l 50 360 l h f Q\n",
];
for cover in covers {
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec();
content.extend_from_slice(cover);
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{:?}: {findings:?}", String::from_utf8_lossy(cover));
assert_eq!(findings[0].kind, FindingKind::CoveredText);
assert_eq!(findings[0].rect, [100.0, 688.0, 320.0, 720.0]);
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
}
}
#[test]
fn non_rectangular_path_is_not_a_cover() {
// Each path's bounding box covers the text, but the shape itself does not hide it all.
let covers: [&[u8]; 5] = [
// A triangle.
b"0 g\n100 688 m 320 688 l 320 720 l h f\n",
// A triangle, then a line from its start point after `h`: not a fourth corner.
b"0 g\n100 688 m 320 688 l 320 720 l h 100 720 l f\n",
// A trapezoid: four corners, one slanted side.
b"0 g\n100 688 m 320 688 l 320 720 l 140 720 l h f\n",
// A rectangle rotated 5 degrees about the text by the CTM.
b"0 g\nq 0.9962 0.0872 -0.0872 0.9962 62.15 -15.63 cm 100 688 m 320 688 l 320 720 l 100 720 l h f Q\n",
// Two subpaths filled together: even-odd leaves the inner one as a hole.
b"0 g\n90 680 m 330 680 l 330 730 l 90 730 l h 100 688 m 320 688 l 320 720 l 100 720 l h f*\n",
];
for cover in covers {
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec();
content.extend_from_slice(cover);
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
assert!(findings.is_empty(), "{:?}: {findings:?}", String::from_utf8_lossy(cover));
}
}

View File

@ -10,10 +10,13 @@ workspace = true
[dependencies]
pdfcraft-engine = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-render = { workspace = true }
pdfcraft-organize = { workspace = true }
pdfcraft-platform = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.11"
tempfile = "3.27"
png = "0.18"
base64 = { version = "0.22", optional = true }
@ -24,7 +27,8 @@ default = ["mcp"]
mcp = ["dep:base64"]
[dev-dependencies]
pdfcraft-cos = { workspace = true }
pdfcraft-fonts = { workspace = true }
flate2.workspace = true
pdfcraft-model = { workspace = true }
pdfcraft-xfa = { workspace = true }
image = { version = "0.25", default-features = false, features = ["jpeg"] }

View File

@ -25,9 +25,54 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS
- Tools that change a document return its summary (`doc`, `pages`, `dirty`, `undo`, `redo`, …).
- `comment_image_preview` returns JSON and a PNG: `layer: "background"` (the default) gives the page without the selected image signature/initials; `layer: "image"` gives its embedded image with alpha. The metadata supplies its displayed rectangle, document rotation, `image_rotation` (how far clockwise the image is shown turned: 0 for one placed by PdfCraft, which keeps it upright as displayed) and annotation opacity. Each result can be saved with the CLI's `--out`. Cache the background and transform the image for live movement/resizing; `comment_edit` commits the final geometry in one undo step. Previewing never changes the document.
- For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle.
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path.
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. Atomic output writes call `sync_all` on the staging file before replacing the destination. A write or sync error leaves the destination intact, removes staging and leaves the document dirty for retry. A filesystem that does not support syncing returns an error; there is no unsynced fallback. This flushes file contents, but does not guarantee persistence of the parent-directory rename across power loss.
- With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (`<path> is outside the allowed directory <root>`), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder.
## Text search and cache limits
Extracted page text is reused in a session-wide LRU cache: at most **64 MiB** of estimated owned
text storage and **4,096 pages**, shared by all open documents. Accounting includes glyph-vector
capacity, each glyph string's capacity, line indexes and word-break flags. Cache hits share the
renderer result instead of copying glyphs. Oversized pages remain usable by the current call but
are not cached; evicted pages can be extracted again. A changed document version invalidates its
cached text on the next text request, and closing a document removes its entries. Cache entries
do not keep an old PDF byte buffer alive.
This bounds retained text-cache data, not total process memory: active extraction batches,
renderers, document buffers and the requested output have separate lifetimes. Search still reads
growing batches (8 to 128 pages), with one lookahead match to determine continuation.
`text_find` keeps matches from readable pages when another reached page fails extraction. It
returns `warnings` with 1-based `page`, `error` and `error_truncated`, plus `warning_count` and
`warnings_truncated`. At most 100 warnings and 1,024 UTF-8 bytes per error are returned. Failures
are retried on later calls; they are not cached as empty pages. Warnings describe pages visited in
search order, up to the continuation point, not failures in unvisited later pages.
`complete: true` means the requested search was exhausted with no page failures and no further
matches. `next: {start_page, skip}` exists only when another match would exceed `limit`. A search
that reaches the end with warnings therefore has `complete: false` **without** `next`. Callers
should follow `next` when present and separately inspect warnings. A capped search can return
both. The existing `query`, `count`, `matches` and rectangle semantics are unchanged.
`text_extract`, comment placement and redaction keep strict all-requested-pages-must-succeed
semantics; they never silently act on partial text.
## Shared field properties
`form_set_props` accepts either `field` (one name) or `fields` (1–1000 unique names):
```json
{"doc": 1, "fields": ["first_name", "last_name"], "required": true,
"appearance": {"width": 3, "border": "#1473E6"}}
```
The batch is atomic and creates one undo step. A missing or locked field, invalid option,
unknown argument or malformed appearance refuses the whole edit. Set `locked: false` to
unlock and change fields together. Only supplied properties change: each field/widget keeps
its other colours, fonts, values and unknown PDF dictionary keys. `"none"` removes a border
or fill colour. Names, rectangles and rotations require the single-field form. Results keep
`field` for single edits and return `fields` for batches. CLI and MCP use the same tool.
## MCP server
**Opt-in only.** Nothing starts it automatically, and it opens no port. It runs while `pdfcraft-cli mcp [--root DIR]` runs, normally launched by an agent from its MCP configuration. It stops when stdin closes. The CLI's `mcp` Cargo feature (on by default) compiles it out entirely when disabled.
@ -36,6 +81,23 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS
Implemented: `initialize` (protocol 2025-06-18, 2025-03-26, 2024-11-05), `ping`, `tools/list` (with `readOnlyHint`/`destructiveHint` annotations), `tools/call` (JSON results also returned as `structuredContent`; images as `image/png`), and `resources/list`, `resources/templates/list` and `resources/read`. The resources expose the open documents read-only: `pdfcraft://doc/{doc}/info` (JSON), `…/text`, `…/page/{page}/text` and `…/page/{page}/image{?dpi}` (PNG, 1–600 dpi). Tool failures come back as `isError: true` results, so the agent can read and recover from them.
### From an installed release
The release packages ship `pdfcraft-cli` alongside the desktop app, so no build is needed:
| Install | CLI |
|---|---|
| Windows (MSI) | `C:\Program Files\PdfCraft\pdfcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` |
| Linux (deb, rpm) | `/usr/bin/pdfcraft-cli` |
| macOS | the separate `pdfcraft-cli-<version>-macos-<arch>.zip` release asset (the `.app` holds only the desktop app) |
```sh
# Windows, default install folder
claude mcp add pdfcraft -- "C:\Program Files\PdfCraft\pdfcraft-cli.exe" mcp --root <dir>
# Linux, or macOS with the CLI unzipped onto PATH
claude mcp add pdfcraft -- pdfcraft-cli mcp --root <dir>
```
## Adding a tool
1. Add the engine capability first, with its tests (the tool is a thin adapter).
@ -44,6 +106,23 @@ Implemented: `initialize` (protocol 2025-06-18, 2025-03-26, 2024-11-05), `ping`,
4. Add an end-to-end test in `tests/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links.
## Moving several page objects
`object_list {doc, page}` inventories recognised paragraphs, Image/Form artwork and added items
with generation-scoped kind/index references. `object_move {doc, page, objects, offset, generation?}`
moves 1–1000 references atomically in displayed points (right/down), preserving source glyph bytes
and fonts. A refusal changes nothing; success is one undo step. See
[the selection and movement guide](../../docs/editing-multiple-objects.md) for limits and examples.
## Accessibility review record
Open the original and edited PDFs as separate documents, then call
`accessibility_review_record {before, after, path}`. The tool writes a JSON record atomically within
`--root`: SHA-256 hashes of both working PDFs, rule statuses and finding counts before and after,
changed rules, and rules still failed or requiring manual review. `all: true` also includes the
colour-contrast rule. It does not change either PDF or certify accessibility. Keep the original open
while making edits to the second document so the comparison has a stable baseline.
## Common command tools
Following FilmCraft's MCP conventions, `command_list {doc?, filter?, enabled_only?}` keeps its
@ -68,3 +147,40 @@ Exports complete synchronously. `progressToken` and cancellation notifications a
ignored; no background-job or cancellation behavior is added.
Session resources `pdfcraft://document` and `pdfcraft://commands`, and the refusal of unknown argument keys, are described in [docs/mcp.md](../../docs/mcp.md).
For inline editor previews, `page_render` accepts `hide_text_block` (a 1-based index from
`text_paragraphs`). It renders the page without that paragraph’s source glyphs and leaves the
working document unchanged.
## Existing text and document fonts
`text_font_files` discovers eligible installed files on request (or specified confined folders),
`text_font_import` registers a full document face, and `text_fonts` lists source/full resources.
Use `text_edit.font_resource` to choose a resource, or keep source faces and let matching full
faces complete absent subset glyphs. `text_edit.ranges` accepts disjoint UTF-8 byte ranges;
`text_edit_layout` measures the same resource/range draft without committing. All changes are
undoable and survive saving/reopening. `image_hit` shares the UI's curve and hole hit testing.
The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md)
and [image editing](../../docs/editing-images.md).
## Opening local GoToR links
`link_open {doc, page, index, password?}` follows a local PDF link using `link_list`
numbering. Relative file specifications open normally. An **absolute** file specification
requires `allow_absolute: true`, after reviewing the target reported by `link_list`.
Omitting the argument or passing false refuses the request before checking or reading the
target file. This is an intentional compatibility change for callers that previously opened
absolute GoToR targets without explicit consent. Opting in does not bypass `--root`, symlink
confinement, regular-file, PDF-extension, size, or password validation. Consent applies only
to that call, including each password retry; it is never remembered for other links.
The desktop UI asks Allow/Cancel before following absolute GoToR targets and displays the
complete path. Relative links retain their existing behavior. Named destinations remain
unapplied with a warning.
## Folder batches
`doc_batch` runs conversion, OCR and compression over files and optionally recursive
folders. The CLI also exposes it as `pdfcraft-cli batch`. See the
[batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits.

View File

@ -1,9 +1,12 @@
//! Accessibility tools: the full check, the accessibility report, and the automatic fixes.
use pdfcraft_engine::DocId;
use pdfcraft_engine::a11y::{Category, Options, Report, Rule, Status};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic};
use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic};
fn status_id(s: Status) -> &'static str {
match s {
@ -31,7 +34,19 @@ pub(crate) fn report_json(r: &Report) -> Value {
.results
.iter()
.map(|x| {
let findings: Vec<Value> = x.findings.iter().map(|f| json!({ "page": f.page.map(|p| p + 1), "message": f.message })).collect();
let findings: Vec<Value> = x
.findings
.iter()
.map(|f| {
json!({
"page": f.page.map(|p| p + 1),
"object": f.object.map(|r| r.num),
"object_generation": f.object.map(|r| r.generation),
"bbox_user_space": f.bbox,
"message": f.message,
})
})
.collect();
json!({ "rule": x.rule.id(), "name": x.rule.name(), "category": category_id(x.rule.category()), "status": status_id(x.status), "findings": findings })
})
.collect();
@ -44,7 +59,183 @@ pub(crate) fn report_json(r: &Report) -> Value {
})
}
fn review_hash(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
fn review_decisions(value: &Value, pages: usize) -> Result<Vec<Value>> {
let rows = value.as_array().ok_or_else(|| ToolError::InvalidArgs("decisions must be an array".into()))?;
let mut seen = BTreeSet::new();
rows.iter()
.map(|row| {
let fields = row.as_object().ok_or_else(|| ToolError::InvalidArgs("each decision must be an object".into()))?;
if fields.keys().any(|key| !matches!(key.as_str(), "rule" | "decision" | "page" | "note")) {
return Err(ToolError::InvalidArgs("unknown decision property".into()));
}
let rule = row["rule"].as_str().and_then(Rule::from_id).ok_or_else(|| ToolError::InvalidArgs("decision has an unknown rule".into()))?;
let decision = row["decision"].as_str().ok_or_else(|| ToolError::InvalidArgs("decision status is required".into()))?;
if !matches!(decision, "accepted" | "needs_work" | "skipped") {
return Err(ToolError::InvalidArgs("decision must be accepted, needs_work or skipped".into()));
}
let page = match row.get("page").filter(|value| !value.is_null()) {
Some(value) => {
let page = value.as_u64().ok_or_else(|| ToolError::InvalidArgs("decision page must be positive".into()))?;
if page == 0 || page > pages as u64 {
return Err(ToolError::InvalidArgs("decision page is outside the document".into()));
}
Some(page)
}
None => None,
};
let note = match row.get("note").filter(|value| !value.is_null()) {
Some(value) => {
let note = value.as_str().ok_or_else(|| ToolError::InvalidArgs("decision note must be text".into()))?;
if note.len() > 4096 {
return Err(ToolError::InvalidArgs("decision note exceeds 4096 bytes".into()));
}
Some(note)
}
None => None,
};
if !seen.insert((rule.id(), page)) {
return Err(ToolError::InvalidArgs("duplicate decision for a rule and page".into()));
}
Ok(json!({ "rule": rule.id(), "decision": decision, "page": page, "note": note }))
})
.collect()
}
/// A review sidecar is a few KiB of JSON; anything larger is not one.
const MAX_REVIEW_SIDECAR_BYTES: u64 = 1 << 20;
/// Read a review sidecar without trusting its size: the path is the caller's
/// and may name any file, so the read stops past `MAX_REVIEW_SIDECAR_BYTES` and
/// an oversized file is refused as not being a sidecar.
fn read_review_sidecar(path: &std::path::Path) -> Result<Vec<u8>> {
use std::io::Read;
let mut bytes = Vec::new();
std::fs::File::open(path).and_then(|file| file.take(MAX_REVIEW_SIDECAR_BYTES.saturating_add(1)).read_to_end(&mut bytes)).map_err(failed)?;
if bytes.len() as u64 > MAX_REVIEW_SIDECAR_BYTES {
return Err(failed("file is not an accessibility review sidecar"));
}
Ok(bytes)
}
fn parse_review_sidecar(bytes: &[u8]) -> Result<Value> {
let value: Value = serde_json::from_slice(bytes).map_err(|_| failed("file is not an accessibility review sidecar"))?;
let hash = value["sha256"].as_str().ok_or_else(|| failed("review sidecar has no document hash"))?;
let Some(pages) = value["pages"].as_u64().and_then(|n| usize::try_from(n).ok()).filter(|n| *n > 0) else {
return Err(failed("unsupported or invalid accessibility review sidecar"));
};
if value["schema"] != 1 || hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(failed("unsupported or invalid accessibility review sidecar"));
}
review_decisions(&value["decisions"], pages).map_err(failed)?;
Ok(value)
}
fn html_escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")
}
/// A self-contained, human-readable view of the same evidence as the JSON review record.
fn review_html(record: &Value) -> Result<String> {
let str_at = |value: &Value, key: &str| -> Result<String> {
value.get(key).and_then(Value::as_str).map(html_escape).ok_or_else(|| failed(format!("review record is missing {key}")))
};
let count = |value: &Value, key: &str| -> Result<u64> {
value.get(key).and_then(Value::as_u64).ok_or_else(|| failed(format!("review record is missing {key}")))
};
let before = &record["before"];
let after = &record["after"];
let mut html = format!(
"<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Accessibility review</title><style>\
body{{font:15px/1.5 system-ui,sans-serif;max-width:1100px;margin:2rem auto;padding:0 1rem;color:#1d1d1f}}\
table{{border-collapse:collapse;width:100%;margin:1rem 0}}th,td{{border:1px solid #aaa;padding:.45rem;text-align:left;vertical-align:top}}\
th{{background:#f2f2f2}}.hash{{overflow-wrap:anywhere;font-family:monospace}}\
</style></head><body><main><h1>Accessibility review</h1><p>Record created: {}</p>\n",
str_at(record, "date")?
);
html.push_str("<h2>Before and after</h2><table><caption>Accessibility checker outcomes</caption><thead><tr><th scope=\"col\">Version</th><th scope=\"col\">Document</th><th scope=\"col\">Passed</th><th scope=\"col\">Failed</th><th scope=\"col\">Needs manual check</th><th scope=\"col\">Skipped</th></tr></thead><tbody>");
for (label, version) in [("Before", before), ("After", after)] {
let summary = &version["summary"];
html.push_str(&format!(
"<tr><th scope=\"row\">{label}</th><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
str_at(version, "name")?,
count(summary, "passed")?,
count(summary, "failed")?,
count(summary, "manual")?,
count(summary, "skipped")?
));
}
html.push_str("</tbody></table>");
html.push_str(&format!(
"<p>Before SHA-256: <span class=\"hash\">{}</span><br>After SHA-256: <span class=\"hash\">{}</span></p>",
str_at(before, "sha256")?,
str_at(after, "sha256")?
));
html.push_str("<h2>Rule comparison</h2><table><caption>Before and after checker results</caption><thead><tr><th scope=\"col\">Rule</th><th scope=\"col\">Before</th><th scope=\"col\">Before findings</th><th scope=\"col\">After</th><th scope=\"col\">After findings</th><th scope=\"col\">Change</th></tr></thead><tbody>");
let rules = record["rules"].as_array().ok_or_else(|| failed("review record is missing rules"))?;
for row in rules {
let id = row["rule"].as_str().ok_or_else(|| failed("review record has a rule without an id"))?;
let name = Rule::from_id(id).map(Rule::name).unwrap_or(id);
let old = &row["before"];
let new = &row["after"];
let old_status = str_at(old, "status")?;
let new_status = str_at(new, "status")?;
let old_findings = count(old, "findings")?;
let new_findings = count(new, "findings")?;
let changed = old_status != new_status || old_findings != new_findings;
html.push_str(&format!(
"<tr><th scope=\"row\">{}</th><td>{old_status}</td><td>{old_findings}</td><td>{new_status}</td><td>{new_findings}</td><td>{}</td></tr>",
html_escape(name),
if changed { "Changed" } else { "Unchanged" }
));
}
html.push_str("</tbody></table><h2>Still requiring review</h2><ul>");
for id in record["review_required"].as_array().ok_or_else(|| failed("review record is missing review_required"))? {
let id = id.as_str().ok_or_else(|| failed("review record has an invalid review_required entry"))?;
html.push_str(&format!("<li>{}</li>", html_escape(Rule::from_id(id).map(Rule::name).unwrap_or(id))));
}
html.push_str("</ul>");
html.push_str(&format!("<p>{}</p></main></body></html>\n", str_at(record, "note")?));
Ok(html)
}
impl Automation {
pub(crate) fn accessibility_autotag_apply(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::AutoTagText).map_err(failed)?;
let tags = self.accessibility_tags(a)?;
Ok(json!({
"tags": tags["tags"], "count": tags["count"],
"note": "Text objects were tagged as paragraphs. Review heading roles, reading order and remaining accessibility findings manually.",
}))
}
pub(crate) fn accessibility_autotag_preview(&self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let preview = self.doc(a)?.autotag_preview(page).map_err(failed)?;
let candidates: Vec<Value> = preview
.candidates
.into_iter()
.map(|c| {
json!({
"type": c.kind, "reason": c.reason, "text": c.text,
"text_truncated": c.text_truncated, "bbox_user_space": c.rect,
"font_size": c.size, "bold": c.bold, "needs_review": true,
})
})
.collect();
Ok(json!({
"page": page + 1, "candidates": candidates,
"unclassified_images": preview.unclassified_images,
"unclassified_text_blocks": preview.unclassified_text_blocks,
"existing_structure": preview.existing_structure,
"note": "Text roles are proposals only. Review images, tables, lists, annotations and existing tags before applying structure.",
}))
}
fn a11y_options(&self, a: &Args) -> Result<Options> {
let mut o = Options::default();
if a.opt_bool("all")?.unwrap_or(false) {
@ -84,13 +275,129 @@ impl Automation {
let r = doc.accessibility_check(&o).ok_or_else(|| failed("the document can't be read for checking"))?;
let path = self.resolve(a.str("path")?, true)?;
let (y, m, d) = self.session.today();
let html = pdfcraft_engine::a11y::report_html(&r, &doc.name, &format!("{y}-{m:02}-{d:02}"));
// A script's report is in English, like the rest of what the tools say and answer with.
let words = pdfcraft_engine::a11y::in_english;
let html = pdfcraft_engine::a11y::report_html(&r, &doc.name, &format!("{y}-{m:02}-{d:02}"), "en", &words);
write_atomic(&path, html.as_bytes())?;
let mut out = report_json(&r);
out["path"] = json!(path.to_string_lossy());
Ok(out)
}
fn review_record_value(&self, a: &Args) -> Result<Value> {
let id = |key| -> Result<DocId> {
let n = u64::try_from(a.int(key)?).map_err(|_| ToolError::InvalidArgs(format!("{key} must be positive")))?;
if n == 0 {
return Err(ToolError::InvalidArgs(format!("{key} must be positive")));
}
Ok(DocId(n))
};
let before_id = id("before")?;
let after_id = id("after")?;
if before_id == after_id {
return Err(ToolError::InvalidArgs("before and after must be separate open documents".into()));
}
let before = self.session.get(before_id).ok_or_else(|| failed(format!("no open document with id {}", before_id.0)))?;
let after = self.session.get(after_id).ok_or_else(|| failed(format!("no open document with id {}", after_id.0)))?;
let mut options = Options::default();
if a.opt_bool("all")?.unwrap_or(false) {
options.rules = Rule::ALL.into_iter().collect();
}
let before_report = before.accessibility_check(&options).ok_or_else(|| failed("the original document can't be read for checking"))?;
let after_report = after.accessibility_check(&options).ok_or_else(|| failed("the edited document can't be read for checking"))?;
if before_report.results.len() != after_report.results.len() {
return Err(failed("the two accessibility checks returned different rule sets"));
}
let mut rules = Vec::new();
let mut changed = Vec::new();
let mut review_required = Vec::new();
for (prior, current) in before_report.results.iter().zip(&after_report.results) {
if prior.rule != current.rule {
return Err(failed("the two accessibility checks returned different rule sets"));
}
let rule = prior.rule.id();
let before_status = status_id(prior.status);
let after_status = status_id(current.status);
let before_findings = prior.findings.len();
let after_findings = current.findings.len();
if before_status != after_status || before_findings != after_findings {
changed.push(rule);
}
if matches!(current.status, Status::Failed | Status::Manual) {
review_required.push(rule);
}
rules.push(json!({
"rule": rule,
"before": { "status": before_status, "findings": before_findings },
"after": { "status": after_status, "findings": after_findings },
}));
}
let (y, m, d) = self.session.today();
let hash = |bytes: &[u8]| Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect::<String>();
let record = json!({
"schema": 1,
"date": format!("{y}-{m:02}-{d:02}"),
"before": { "name": before.name, "sha256": hash(before.bytes.as_slice()), "summary": {
"passed": before_report.count(Status::Passed), "failed": before_report.count(Status::Failed),
"manual": before_report.count(Status::Manual), "skipped": before_report.count(Status::Skipped),
} },
"after": { "name": after.name, "sha256": hash(after.bytes.as_slice()), "summary": {
"passed": after_report.count(Status::Passed), "failed": after_report.count(Status::Failed),
"manual": after_report.count(Status::Manual), "skipped": after_report.count(Status::Skipped),
} },
"rules": rules,
"changed": changed,
"review_required": review_required,
"note": "Automated checks cover only part of accessibility. Review remaining findings manually and validate the final PDF independently.",
});
Ok(record)
}
pub(crate) fn a11y_review_record(&self, a: &Args) -> Result<Value> {
let record = self.review_record_value(a)?;
let path = self.resolve(a.str("path")?, true)?;
let bytes = serde_json::to_vec_pretty(&record).map_err(failed)?;
write_atomic(&path, &bytes)?;
Ok(json!({ "path": path.to_string_lossy(), "record": record }))
}
pub(crate) fn a11y_review_save(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let decisions = review_decisions(a.get("decisions").ok_or_else(|| Args::missing("decisions"))?, doc.info.pages.len())?;
let hash = review_hash(doc.bytes.as_slice());
let path = self.resolve(a.str("path")?, true)?;
if path.exists() {
let prior = read_review_sidecar(&path)?;
let prior = parse_review_sidecar(&prior)?;
if prior["sha256"] != hash && !a.opt_bool("replace")?.unwrap_or(false) {
return Err(failed("review sidecar belongs to a different PDF revision; pass replace: true to replace it"));
}
}
let (y, m, d) = self.session.today();
let sidecar =
json!({ "schema": 1, "date": format!("{y}-{m:02}-{d:02}"), "sha256": hash, "pages": doc.info.pages.len(), "decisions": decisions });
write_atomic(&path, &serde_json::to_vec_pretty(&sidecar).map_err(failed)?)?;
Ok(json!({ "path": path.to_string_lossy(), "review": sidecar }))
}
pub(crate) fn a11y_review_load(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let path = self.resolve(a.str("path")?, false)?;
let review = parse_review_sidecar(&read_review_sidecar(&path)?)?;
if review["sha256"] != review_hash(doc.bytes.as_slice()) {
return Err(failed("review sidecar belongs to a different PDF revision"));
}
Ok(json!({ "path": path.to_string_lossy(), "review": review }))
}
pub(crate) fn a11y_review_html(&self, a: &Args) -> Result<Value> {
let record = self.review_record_value(a)?;
let html = review_html(&record)?;
let path = self.resolve(a.str("path")?, true)?;
write_atomic(&path, html.as_bytes())?;
Ok(json!({ "path": path.to_string_lossy(), "record": record }))
}
pub(crate) fn a11y_fix(&mut self, a: &Args) -> Result<Value> {
let id = a.str("rule")?;
let rule = Rule::from_id(id).ok_or_else(|| ToolError::InvalidArgs(format!("unknown rule {id:?}")))?;
@ -133,10 +440,67 @@ impl Automation {
self.session.apply(id, edit).map_err(failed)?;
self.accessibility_figures(a)
}
pub(crate) fn accessibility_tags(&self, a: &Args) -> Result<Value> {
let tags: Vec<Value> = self
.doc(a)?
.tags()
.into_iter()
.map(|t| {
json!({
"object": t.object.num, "generation": t.object.generation, "type": t.kind,
"parent": t.parent.map(|p| json!({ "object": p.num, "generation": p.generation })), "depth": t.depth,
"title": t.title, "actual_text": t.actual_text, "alternate_text": t.alt,
"language": t.language, "page": t.page.map(|p| p + 1),
})
})
.collect();
Ok(json!({ "count": tags.len(), "tags": tags }))
}
pub(crate) fn accessibility_set_tag_property(&mut self, a: &Args) -> Result<Value> {
let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?;
if object == 0 {
return Err(ToolError::InvalidArgs("object must be a positive tag object number".into()));
}
let generation = u16::try_from(a.int("generation")?)
.map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?;
let property_id = a.str("property")?;
let property = pdfcraft_engine::a11y::TagProperty::from_id(property_id)
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown tag property {property_id:?}")))?;
let value = a.opt_str("value")?.map(str::to_owned);
let id = self.doc(a)?.id;
self.session
.apply(id, pdfcraft_engine::Edit::SetTagProperty { object: pdfcraft_cos::ObjRef::new(object, generation), property, value })
.map_err(failed)?;
self.accessibility_tags(a)
}
pub(crate) fn accessibility_move_tag(&mut self, a: &Args) -> Result<Value> {
let reference = |number: &str, generation: &str| -> Result<pdfcraft_cos::ObjRef> {
let n = u32::try_from(a.int(number)?).map_err(|_| ToolError::InvalidArgs(format!("{number} must be a positive tag object number")))?;
if n == 0 {
return Err(ToolError::InvalidArgs(format!("{number} must be a positive tag object number")));
}
let g = u16::try_from(a.int(generation)?)
.map_err(|_| ToolError::InvalidArgs(format!("{generation} must be a PDF object generation from accessibility_tags")))?;
Ok(pdfcraft_cos::ObjRef::new(n, g))
};
let object = reference("object", "generation")?;
let anchor = reference("anchor", "anchor_generation")?;
let placement = match a.str("placement")? {
"before" => pdfcraft_engine::a11y::TagPlacement::Before,
"after" => pdfcraft_engine::a11y::TagPlacement::After,
other => return Err(ToolError::InvalidArgs(format!("placement must be before or after, not {other:?}"))),
};
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::MoveTag { object, anchor, placement }).map_err(failed)?;
self.accessibility_tags(a)
}
}
impl Automation {
fn ocr_settings(&self, a: &Args) -> Result<pdfcraft_engine::ocr::OcrSettings> {
pub(crate) fn ocr_settings(&self, a: &Args) -> Result<pdfcraft_engine::ocr::OcrSettings> {
let mut settings = pdfcraft_engine::ocr::OcrSettings::default();
if let Some(d) = a.opt_num("dpi")? {
settings.dpi = d.clamp(72.0, 600.0) as f32;
@ -156,10 +520,16 @@ impl Automation {
pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result<Value> {
let settings = self.ocr_settings(a)?;
let folder = self.resolve(a.str("folder")?, true)?;
// A path that leaves the root refuses the whole call before any work starts, like the
// other tools (the write check passes missing files, which stay per-file errors below).
let paths = a.strs("paths")?;
for p in &paths {
self.resolve(p, true)?;
}
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?;
let mut out = Vec::new();
for p in a.strs("paths")? {
for p in paths {
let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into());
let result =
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())).and_then(|bytes| {
@ -187,7 +557,7 @@ impl Automation {
.iter()
.map(|p| match &p.skipped {
Some(why) => json!({ "page": p.page + 1, "skipped": why }),
None => json!({ "page": p.page + 1, "words": p.words.len(), "text": p.text() }),
None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }),
})
.collect();
Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::<usize>(), "pages": list }))
@ -264,8 +634,8 @@ impl Automation {
let path = self.resolve(a.str("path")?, true)?;
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext)
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, html or rtf)")))?;
let bytes = self.doc(a)?.export_office(format);
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?;
let bytes = self.doc_to_copy(a)?.export_office(format);
write_atomic(&path, &bytes)?;
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() }))
}
@ -292,6 +662,56 @@ impl Automation {
}))
}
pub(crate) fn print_readiness(&self, a: &Args) -> Result<Value> {
let report = self.doc(a)?.print_readiness();
let findings: Vec<Value> =
report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect();
let pages: Vec<Value> = report
.pages
.iter()
.map(|p| {
json!({
"page": p.page,
"media_mm": p.media_mm,
"crop_mm": p.crop_mm,
"trim_mm": p.trim_mm,
"bleed_mm": p.bleed_mm,
"trim_set": p.trim_set,
"bleed_set": p.bleed_set,
"bleed_margin_mm": p.bleed_margin_mm,
})
})
.collect();
let images: Vec<Value> = report
.images
.iter()
.map(|image| {
json!({
"page": image.page,
"name": image.name,
"ppi": image.ppi,
"pixels": image.pixels,
"size_mm": image.size_mm,
})
})
.collect();
let pdfa: Vec<Value> = report
.pdfa_issues
.iter()
.map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) }))
.collect();
Ok(json!({
"text": report.text(),
"findings": findings,
"pages": pages,
"images": images,
"output_intents": report.output_intents,
"fonts_not_embedded": report.fonts_not_embedded,
"pdfa_issues": pdfa,
"resolution_unknown": report.resolution_unknown,
}))
}
pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result<Value> {
let level = self.pdfa_level(a)?;
let id = self.doc(a)?.id;
@ -341,6 +761,16 @@ impl Automation {
out.push(match result {
Ok(r) => {
let target = child(&folder, &name);
let target = if target.exists() {
let stem =
std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into());
unused(&folder, &stem)
} else {
target
};
if target.exists() {
return Err(failed(format!("{}: no unused output name available", target.display())));
}
write_atomic(&target, &r.bytes)?;
json!({ "path": p, "output": target.to_string_lossy(), "log": r.log })
}
@ -498,3 +928,23 @@ impl Automation {
Ok(json!({ "enabled": self.session.javascript() }))
}
}
#[cfg(test)]
mod review_html_tests {
use super::*;
#[test]
fn document_names_are_escaped_in_review_html() {
let record = json!({
"date": "2026-10-12",
"before": { "name": "<script>alert('x')</script>.pdf", "sha256": "a", "summary": { "passed": 0, "failed": 1, "manual": 0, "skipped": 0 } },
"after": { "name": "safe.pdf", "sha256": "b", "summary": { "passed": 1, "failed": 0, "manual": 0, "skipped": 0 } },
"rules": [{ "rule": "primary-language", "before": { "status": "failed", "findings": 1 }, "after": { "status": "passed", "findings": 0 } }],
"review_required": [],
"note": "Review independently.",
});
let html = review_html(&record).unwrap();
assert!(html.contains("&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;.pdf"));
assert!(!html.contains("<script>"));
}
}

View File

@ -0,0 +1,276 @@
//! One-shot folder jobs shared by CLI and MCP. Discovery finishes before any output is made.
use std::collections::{BTreeMap, HashSet};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use pdfcraft_engine::{Session, ocr, optimize};
use serde_json::{Value, json};
use crate::{Args, Automation, Result, failed};
const MAX_ENTRIES: usize = 10_000;
const MAX_DEPTH: usize = 64;
const MAX_INPUT: u64 = 512 * 1024 * 1024;
struct Recipe<'a> {
steps: Vec<&'a str>,
images: optimize::ImageSettings,
ocr: ocr::OcrSettings,
}
impl<'a> Recipe<'a> {
fn parse(auto: &Automation, args: &'a Args) -> Result<Self> {
let steps = args.strs("steps")?;
if steps.is_empty() || steps.len() > 3 {
return Err(Args::wrong("steps", "one to three unique steps: convert, ocr, optimize"));
}
let mut seen = HashSet::new();
for (i, step) in steps.iter().enumerate() {
if !matches!(*step, "convert" | "ocr" | "optimize") || !seen.insert(*step) || (*step == "convert" && i != 0) {
return Err(Args::wrong("steps", "unique convert/ocr/optimize steps, with convert first when present"));
}
}
for (keys, step) in [(&["ppi", "quality"][..], "optimize"), (&["dpi", "language", "skip_text_pages"][..], "ocr")] {
if !steps.contains(&step) && keys.iter().any(|k| args.get(k).is_some()) {
return Err(failed(format!("{} settings need the {step} step", keys.join("/"))));
}
}
let mut images = optimize::ImageSettings::REDUCE;
if let Some(ppi) = args.opt_num("ppi")? {
if !(9.0..=1600.0).contains(&ppi) {
return Err(Args::wrong("ppi", "9 to 1600"));
}
images.target_ppi = ppi;
images.above_ppi = ppi * 1.5;
}
if let Some(q) = args.opt_int("quality")? {
if !(1..=100).contains(&q) {
return Err(Args::wrong("quality", "1 to 100"));
}
images.compression = optimize::Compression::Jpeg(q as u8);
}
if args.opt_num("dpi")?.is_some_and(|dpi| !(72.0..=600.0).contains(&dpi)) {
return Err(Args::wrong("dpi", "72 to 600"));
}
Ok(Self { steps, images, ocr: auto.ocr_settings(args)? })
}
fn accepts(&self, path: &Path) -> bool {
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("").to_ascii_lowercase();
extension == "pdf" || (self.steps.contains(&"convert") && pdfcraft_engine::CONVERTIBLE.contains(&extension.as_str()))
}
fn process(&self, path: &Path) -> Result<(Arc<Vec<u8>>, Vec<Value>)> {
// A fresh session per file bounds retained document state and never touches caller tabs.
let mut session = Session::new();
let mut input = Vec::new();
std::fs::File::open(path).map_err(failed)?.take(MAX_INPUT + 1).read_to_end(&mut input).map_err(failed)?;
if input.len() as u64 > MAX_INPUT {
return Err(failed("input exceeds 512 MiB"));
}
let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| failed("input name is not UTF-8"))?;
let mut bytes = Arc::new(input);
let mut log = Vec::new();
for step in &self.steps {
let before = bytes.len();
match *step {
"convert" => bytes = session.convert_to_pdf(name, &bytes)?.1,
"ocr" => {
let engine = ocr::engine().map_err(failed)?;
let result = ocr::recognize_file(name, bytes, None, self.ocr.clone(), &engine, |_, _| true).map_err(failed)?;
log.push(
json!({"step":"ocr","words":result.words(),"skipped_pages":result.pages.iter().filter(|p| p.skipped.is_some()).count()}),
);
bytes = result.bytes;
continue;
}
"optimize" => {
let id = session.open(name, None, bytes.clone(), None).map_err(failed)?;
let settings = optimize::Settings { color: self.images, gray: self.images, ..Default::default() };
let (candidate, _) = session.optimized_bytes(id, &settings, &[])?;
session.close(id);
if candidate.len() < bytes.len() {
bytes = candidate;
}
}
_ => return Err(failed("unknown batch step")),
}
log.push(json!({"step":step,"bytes_before":before,"bytes_after":bytes.len()}));
}
Ok((bytes, log))
}
}
// Windows junctions are reparse points too; do not descend into them as ordinary folders.
fn linked(metadata: &std::fs::Metadata) -> bool {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
metadata.file_attributes() & 0x400 != 0
}
#[cfg(not(windows))]
{
metadata.file_type().is_symlink()
}
}
/// Canonicalize the existing ancestor, including when the requested output does not exist yet.
fn absolute(path: &Path) -> Result<PathBuf> {
let mut ancestor = path;
let mut tail = Vec::new();
while !ancestor.exists() {
if ancestor.symlink_metadata().is_ok() {
return Err(failed("broken link in batch path"));
}
tail.push(ancestor.file_name().ok_or_else(|| failed("invalid batch path"))?);
ancestor = ancestor.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
}
let mut path = ancestor.canonicalize().map_err(failed)?;
path.extend(tail.iter().rev());
crate::lexical(&path).ok_or_else(|| failed("invalid batch path"))
}
struct Discovery {
files: BTreeMap<PathBuf, PathBuf>,
skipped: Vec<Value>,
seen: HashSet<PathBuf>,
visited: usize,
}
impl Discovery {
fn walk(&mut self, path: &Path, relative: &Path, out: &Path, recursive: bool, depth: usize) -> Result<()> {
self.visited += 1;
if self.visited > MAX_ENTRIES || depth > MAX_DEPTH {
return Err(failed("batch discovery exceeds 10000 entries or 64 folder levels"));
}
if depth > 0 && path.starts_with(out) {
return Ok(());
}
let metadata = path.symlink_metadata().map_err(|e| failed(format!("{}: {e}", path.display())))?;
if linked(&metadata) || (!metadata.is_file() && !metadata.is_dir()) {
self.skipped.push(json!({"path":path.to_string_lossy(),"status":"skipped","reason":"link or special file"}));
} else if (metadata.is_file() || depth == 0 || recursive) && self.seen.insert(path.to_path_buf()) {
if metadata.is_file() {
self.files.insert(path.to_path_buf(), relative.to_path_buf());
} else if depth == 0 || recursive {
// Bound the list before sorting it; never collect an unbounded folder listing.
let mut entries = Vec::new();
for entry in std::fs::read_dir(path).map_err(failed)? {
if entries.len() >= MAX_ENTRIES.saturating_sub(self.visited) {
return Err(failed("batch discovery exceeds 10000 entries"));
}
entries.push(entry.map_err(failed)?.path());
}
entries.sort();
for child in entries {
let name = child.file_name().ok_or_else(|| failed("entry has no name"))?;
self.walk(&child, &relative.join(name), out, recursive, depth + 1)?;
}
}
}
Ok(())
}
}
impl Automation {
pub(crate) fn doc_batch(&self, args: &Args) -> Result<Value> {
let recipe = Recipe::parse(self, args)?;
let recursive = args.opt_bool("recursive")?.unwrap_or(false);
let stop = args.opt_bool("stop_on_error")?.unwrap_or(false);
let paths = args.strs("paths")?;
if paths.is_empty() || paths.len() > 1000 {
return Err(Args::wrong("paths", "1 to 1000 files or folders"));
}
let out = absolute(&self.resolve(args.str("out_dir")?, true)?)?;
let mut roots = Vec::new();
for path in paths {
let resolved = self.resolve(path, false)?;
let original = self.root.as_ref().map_or_else(|| PathBuf::from(path), |root| root.join(path));
if linked(&original.symlink_metadata().map_err(failed)?) {
return Err(failed("input roots must be regular files or folders, not links"));
}
let real = absolute(&resolved)?;
if real.is_dir() && real.starts_with(&out) {
return Err(failed("out_dir must not contain an input folder or equal it"));
}
roots.push(real);
}
let mut discovery = Discovery { files: BTreeMap::new(), skipped: Vec::new(), seen: HashSet::new(), visited: 0 };
for root in roots {
let relative = if root.is_dir() { PathBuf::new() } else { PathBuf::from(root.file_name().ok_or_else(|| failed("input has no name"))?) };
discovery.walk(&root, &relative, &out, recursive, 0)?;
}
let mut files = discovery.skipped;
let (mut completed, mut failed_count) = (0, 0);
let mut stopped = false;
for (path, relative) in discovery.files {
if stopped || !recipe.accepts(&path) {
files.push(json!({"path":path.to_string_lossy(),"status":"skipped","reason":if stopped {"stopped after error"} else {"unsupported input extension"}}));
continue;
}
// Recheck confinement and links before reading a discovered file.
let result: Result<_> = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let current = self.resolve(path.to_str().ok_or_else(|| failed("input path is not UTF-8"))?, false)?;
if linked(&path.symlink_metadata().map_err(failed)?) || absolute(&current)? != path {
return Err(failed("input path changed after discovery"));
}
let (bytes, log) = recipe.process(&current)?;
let target = out.join(relative).with_extension("pdf");
let target = self.resolve(target.to_str().ok_or_else(|| failed("output path is not UTF-8"))?, true)?;
if !target.starts_with(&out) {
return Err(failed("output path leaves out_dir"));
}
let output = publish(&target, &bytes)?;
Ok((output, bytes.len(), log))
}))
.unwrap_or_else(|_| Err(failed("batch conversion panicked; no output was published")));
match result {
Ok((output, bytes, steps)) => {
completed += 1;
files.push(json!({"path":path.to_string_lossy(),"status":"ok","output":output.to_string_lossy(),"bytes":bytes,"steps":steps}));
}
Err(e) => {
failed_count += 1;
files.push(json!({"path":path.to_string_lossy(),"status":"error","error":e.to_string()}));
stopped = stop;
}
}
}
let skipped = files.len().saturating_sub(completed + failed_count);
Ok(json!({"completed":completed,"failed":failed_count,"skipped":skipped,"stopped":stopped,"files":files}))
}
}
/// Publish atomically without replacing any existing name, even if another writer wins a race.
fn publish(target: &Path, bytes: &[u8]) -> Result<PathBuf> {
let dir = target.parent().ok_or_else(|| failed("output has no parent"))?;
std::fs::create_dir_all(dir).map_err(failed)?;
let mut temp = tempfile::NamedTempFile::new_in(dir).map_err(failed)?;
temp.write_all(bytes).map_err(failed)?;
temp.as_file().sync_all().map_err(failed)?;
let stem = target.file_stem().ok_or_else(|| failed("output has no stem"))?.to_string_lossy();
for n in 1..=10_000 {
let candidate = if n == 1 { target.to_path_buf() } else { dir.join(format!("{stem} ({n}).pdf")) };
match temp.persist_noclobber(&candidate) {
Ok(_) => return Ok(candidate),
Err(e) if e.error.kind() == std::io::ErrorKind::AlreadyExists || candidate.symlink_metadata().is_ok() => temp = e.file,
Err(e) => return Err(failed(e.error)),
}
}
Err(failed("all 10000 output names are occupied"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discovery_limits_fail_before_reading_another_entry() {
let mut discovery = Discovery { files: BTreeMap::new(), skipped: Vec::new(), seen: HashSet::new(), visited: MAX_ENTRIES };
assert!(discovery.walk(Path::new("absent"), Path::new(""), Path::new("output"), true, 0).unwrap_err().to_string().contains("10000"));
discovery.visited = 0;
assert!(discovery.walk(Path::new("absent"), Path::new(""), Path::new("output"), true, MAX_DEPTH + 1).unwrap_err().to_string().contains("64"));
}
}

View File

@ -191,8 +191,21 @@ impl Automation {
.filter(|c| c.in_reply_to.is_none() && only.is_none_or(|p| c.page == p))
.map(|c| {
let mut v = view(c);
let thread: Vec<&Annotation> = match &c.name {
Some(nm) => all.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).collect(),
// Every reply under this comment, at any depth, in document order. A reply to a
// reply keeps its `/IRT` on the direct parent, so following one level would drop it.
let descends_from = |r: &Annotation, root: &str| -> bool {
let mut parent = r.in_reply_to.as_deref();
for _ in 0..64 {
let Some(nm) = parent else { return false };
if nm == root {
return true;
}
parent = all.iter().find(|x| x.name.as_deref() == Some(nm)).and_then(|x| x.in_reply_to.as_deref());
}
false
};
let thread: Vec<&Annotation> = match c.name.as_deref() {
Some(nm) => all.iter().filter(|r| r.in_reply_to.is_some() && descends_from(r, nm)).collect(),
None => Vec::new(),
};
let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone());
@ -456,17 +469,45 @@ impl Automation {
let at = to_user(&info, x, y);
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
let kind = a.str("type")?;
let scan = a.opt_bool("scan")?.unwrap_or(false);
let sensitivity = a.opt_int("sensitivity")?;
if (scan || sensitivity.is_some()) && a.opt_str("path")?.is_none() {
return Err(ToolError::InvalidArgs("scan and sensitivity need path, a photo of a signature on paper".into()));
}
if sensitivity.is_some() && !scan {
return Err(ToolError::InvalidArgs("sensitivity is only for scan: true".into()));
}
let requested_size = a.opt_num("font_size")?;
if requested_size.is_some() && !matches!(kind, "text" | "date") {
return Err(ToolError::InvalidArgs("font_size is only for type text or date".into()));
}
let size = requested_size.unwrap_or(10.0);
if !(4.0..=300.0).contains(&size) {
return Err(ToolError::InvalidArgs("font_size must be 4–300 pt".into()));
}
if let Some(path) = a.opt_str("path")? {
if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() {
return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into()));
}
let path = self.resolve(path, false)?;
let file = std::fs::File::open(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let image = pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
let image = if scan {
let sensitivity = match sensitivity {
None => pdfcraft_engine::signature_scan::DEFAULT_SENSITIVITY,
Some(s) => u8::try_from(s)
.ok()
.filter(|s| *s <= 100)
.ok_or_else(|| ToolError::InvalidArgs(format!("sensitivity must be 0–100, got {s}")))?,
};
let mut photo = pdfcraft_engine::SignaturePhoto::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
let frame = photo.auto_frame();
photo.scan(frame, sensitivity, pdfcraft_engine::signature_scan::SCAN_MAX_SIDE).map_err(|e| ToolError::InvalidArgs(e.to_string()))?
} else {
pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?
};
let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
return self.apply(a, edit);
}
let size = 10.0;
let text_at = |t: &str| {
let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0);
let h = size * 1.2 + 6.0;
@ -488,6 +529,14 @@ impl Automation {
// A typed signature or initials in the script font, left edge at `at`, upright as displayed.
kind @ ("signature" | "initials") => {
let t = a.str("text")?;
// Nobody sees a preview here: refuse what the script font would leave out.
if let Some(c) = pdfcraft_engine::script_missing(t) {
return Err(ToolError::InvalidArgs(format!(
"the signature script font can't draw \"{}\" (U+{:04X}); type the {kind} in Latin, Cyrillic or Greek letters, or pass `path` to a PNG or JPEG image of it instead of `text`",
c.escape_debug(),
u32::from(c)
)));
}
let h = if kind == "initials" { 24.0 } else { 32.0 };
let shape = pdfcraft_engine::typed_signature_shape(at, t, h, i64::from(info.rotation))
.ok_or_else(|| ToolError::InvalidArgs("text has nothing to draw".into()))?;
@ -565,6 +614,10 @@ impl Automation {
if color.is_some() || opacity.is_some() || width.is_some() {
edits.push(Edit::StyleAnnotation { page, index, color, opacity, width, endings: None });
}
if let Some(f) = a.opt_str("fill")? {
let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) };
edits.push(Edit::FillAnnotation { page, index, fill });
}
if let Some(r) = a.nums::<4>("rect")? {
edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) });
}

View File

@ -164,6 +164,9 @@ impl Automation {
if a.get("text").is_some() {
return Err(bad("images have no text"));
}
if ["font", "size", "bold", "italic", "color", "align"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("font, size, bold, italic, color and align apply to text"));
}
if let Some(deg) = a.opt_int("rotate")? {
if deg.rem_euclid(90) != 0 {
return Err(bad("rotate must be a multiple of 90"));
@ -200,3 +203,71 @@ impl Automation {
self.apply(a, Edit::DeleteContent { page, index })
}
}
impl Automation {
/// One mixed inventory, with exact source references and the current document generation.
pub(crate) fn object_list(&self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let doc = self.doc(a)?;
let info = doc.info.pages.get(page).ok_or_else(|| bad("the page no longer exists"))?;
let objects = doc.editable_objects(page).map_err(failed)?;
let items = objects
.iter()
.map(|o| {
let rect = crate::comments::rect_to_view(info, o.rect.map(|v| v as f32));
json!({"kind":o.target.kind.as_str(),"index":o.target.index.saturating_add(1),"type":o.content_type,
"rect":rect.map(|v|(f64::from(v)*100.0).round()/100.0),"text":o.text})
})
.collect::<Vec<_>>();
Ok(json!({"page":page+1,"generation":doc.edit_generation(),"count":items.len(),"objects":items}))
}
pub(crate) fn object_move(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::{MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
let page = self.page(a)?;
let rows = a
.get("objects")
.and_then(Value::as_array)
.filter(|r| !r.is_empty() && r.len() <= MAX_MOVE_OBJECTS)
.ok_or_else(|| bad("objects must contain 1–1000 references from object_list"))?;
let mut targets = Vec::with_capacity(rows.len());
for row in rows {
let row = row.as_object().ok_or_else(|| bad("each object must contain kind and index"))?;
if row.keys().any(|k| k != "kind" && k != "index") {
return Err(bad("an object reference accepts only kind and index"));
}
let kind = match row.get("kind").and_then(Value::as_str) {
Some("added") => ObjectKind::Added,
Some("text") => ObjectKind::Text,
Some("image") => ObjectKind::Image,
_ => return Err(bad("object kind must be added, text or image (see object_list)")),
};
let index = row
.get("index")
.and_then(Value::as_u64)
.and_then(|i| i.checked_sub(1))
.and_then(|i| usize::try_from(i).ok())
.ok_or_else(|| bad("object index must be a positive integer from object_list"))?;
targets.push(ObjectTarget { kind, index });
}
let delta = a
.get("offset")
.and_then(Value::as_array)
.filter(|v| v.len() == 2)
.and_then(|v| Some([v.first()?.as_f64()?, v.get(1)?.as_f64()?]))
.filter(|v| v.iter().all(|n| n.is_finite() && n.abs() <= 1e9))
.ok_or_else(|| bad("offset must contain two finite numbers [dx, dy] in displayed points"))?;
let doc = self.doc(a)?;
if let Some(g) = a.get("generation") {
let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?;
if g != doc.edit_generation() {
return Err(failed("the document changed; call object_list again before moving"));
}
}
let offset = doc.object_move_offset(page, delta).map_err(failed)?;
let count = targets.len();
let mut result = self.apply(a, Edit::MoveObjects { page, objects: targets, offset })?;
result["moved"] = json!(count);
Ok(result)
}
}

View File

@ -114,7 +114,7 @@ impl Automation {
return Err(failed("page has invalid dimensions"));
}
let scale = (max as f32 / side).min(1.0);
let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0 });
let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false });
if let Some(error) = out.error {
return Err(failed(error));
}

View File

@ -43,9 +43,14 @@ fn format_arg(v: &Value) -> Result<Format> {
/// `{"border": "#FF0000" | "none", "fill": …, "width": 1-3, "style": "solid"|…, "text_color": …,
/// "font": "helvetica"|"times"|"courier"}`, changing only what is given.
fn look_arg(v: &Value, mut l: pdfcraft_engine::FieldLook) -> Result<pdfcraft_engine::FieldLook> {
use pdfcraft_engine::{BorderStyle, FieldFont};
fn look_arg(v: &Value) -> Result<pdfcraft_engine::FieldLookPatch> {
use pdfcraft_engine::{BorderStyle, FieldFont, FieldLookPatch};
let o = v.as_object().ok_or_else(|| bad("appearance must be an object"))?;
for key in o.keys() {
if !["border", "fill", "text_color", "width", "style", "font"].contains(&key.as_str()) {
return Err(bad(format!("unknown appearance property {key:?}")));
}
}
let colour = |k: &str| -> Result<Option<Option<[f64; 3]>>> {
match o.get(k) {
None => Ok(None),
@ -54,37 +59,39 @@ fn look_arg(v: &Value, mut l: pdfcraft_engine::FieldLook) -> Result<pdfcraft_eng
Some(_) => Err(bad(format!("{k} must be a colour or \"none\""))),
}
};
if let Some(c) = colour("border")? {
l.border = c;
}
if let Some(c) = colour("fill")? {
l.fill = c;
}
if let Some(c) = colour("text_color")? {
l.text = c.unwrap_or([0.0; 3]);
}
if let Some(w) = o.get("width").and_then(Value::as_f64) {
l.width = w.clamp(0.0, 12.0);
}
if let Some(s) = o.get("style").and_then(Value::as_str) {
l.style = match s {
"solid" => BorderStyle::Solid,
"dashed" => BorderStyle::Dashed,
"beveled" => BorderStyle::Beveled,
"inset" => BorderStyle::Inset,
"underline" => BorderStyle::Underline,
x => return Err(bad(format!("unknown style {x:?}"))),
};
}
if let Some(f) = o.get("font").and_then(Value::as_str) {
l.font = match f {
"helvetica" => FieldFont::Helvetica,
"times" => FieldFont::Times,
"courier" => FieldFont::Courier,
x => return Err(bad(format!("unknown font {x:?} (helvetica, times, courier)"))),
};
}
Ok(l)
let string =
|k: &str| -> Result<Option<&str>> { o.get(k).map(|v| v.as_str().ok_or_else(|| bad(format!("appearance.{k} must be a string")))).transpose() };
Ok(FieldLookPatch {
border: colour("border")?,
fill: colour("fill")?,
text: colour("text_color")?.map(|c| c.unwrap_or([0.0; 3])),
width: o
.get("width")
.map(|v| {
v.as_f64()
.filter(|v| v.is_finite() && (0.0..=12.0).contains(v))
.ok_or_else(|| bad("appearance.width must be between 0 and 12 points"))
})
.transpose()?,
style: string("style")?
.map(|s| match s {
"solid" => Ok(BorderStyle::Solid),
"dashed" => Ok(BorderStyle::Dashed),
"beveled" => Ok(BorderStyle::Beveled),
"inset" => Ok(BorderStyle::Inset),
"underline" => Ok(BorderStyle::Underline),
x => Err(bad(format!("unknown style {x:?}"))),
})
.transpose()?,
font: string("font")?
.map(|s| match s {
"helvetica" => Ok(FieldFont::Helvetica),
"times" => Ok(FieldFont::Times),
"courier" => Ok(FieldFont::Courier),
x => Err(bad(format!("unknown font {x:?} (helvetica, times, courier)"))),
})
.transpose()?,
})
}
/// `{"min": 0, "max": 100}` or `"none"`.
@ -320,7 +327,59 @@ impl Automation {
}
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result<Value> {
let name = a.str("field")?.to_owned();
let single = a.opt_str("field")?;
let bulk = a.get("fields").is_some();
if single.is_some() == bulk {
return Err(bad("give either field or fields, not both"));
}
let names: Vec<String> = if bulk {
let count = a.get("fields").and_then(Value::as_array).ok_or_else(|| bad("fields must be an array of field names"))?.len();
if count == 0 || count > 1_000 {
return Err(bad("fields must contain between 1 and 1000 names"));
}
let names = a.strs("fields")?;
let mut seen = std::collections::HashSet::new();
for name in &names {
if !seen.insert(*name) {
return Err(bad(format!("field {name:?} is listed more than once")));
}
}
for key in ["name", "rect", "rotation"] {
if a.get(key).is_some() {
return Err(bad(format!("{key} applies to one field; use field instead of fields")));
}
}
names.into_iter().map(str::to_owned).collect()
} else {
single.into_iter().map(str::to_owned).collect()
};
let mut edits = Vec::with_capacity(names.len());
let mut new_name = None;
for name in &names {
// Merge appearance changes separately for each field: changing just the border
// must not copy the first field's fill, font or text colour onto the others.
let props = self.field_props_arg(a, name)?;
if props == FieldProps::default() {
return Err(bad("nothing to change"));
}
new_name = Some(props.name.clone().unwrap_or_else(|| name.clone()));
edits.push(Edit::SetFieldProps { name: name.clone(), props: Box::new(props) });
}
let edit = if bulk {
Edit::Batch { label: "Change field properties".into(), edits }
} else {
edits.pop().ok_or_else(|| bad("give a field name"))?
};
let mut out = self.apply(a, edit)?;
if bulk {
out["fields"] = json!(names);
} else {
out["field"] = json!(new_name);
}
Ok(out)
}
fn field_props_arg(&self, a: &Args, name: &str) -> Result<FieldProps> {
let doc = self.doc(a)?;
let f = doc.form.iter().find(|f| f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?;
// Position: top-left-origin points on the displayed page → user space.
@ -335,7 +394,7 @@ impl Automation {
Some((0, [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]))
}
};
let props = FieldProps {
Ok(FieldProps {
rect,
name: a.opt_str("name")?.map(str::to_owned),
tooltip: a.opt_str("tooltip")?.map(str::to_owned),
@ -350,10 +409,8 @@ impl Automation {
},
options: a.get("options").map(|_| a.strs("options")).transpose()?.map(|v| v.into_iter().map(str::to_owned).collect()),
font_size: a.opt_num("font_size")?,
look: match a.get("appearance") {
None => None,
Some(v) => Some(look_arg(v, self.doc(a)?.field_look(&name).ok_or_else(|| failed("this field has no widget"))?)?),
},
look: None,
appearance: a.get("appearance").map(look_arg).transpose()?.filter(|p| *p != pdfcraft_engine::FieldLookPatch::default()),
format: a.get("format").map(format_arg).transpose()?,
validate: a.get("validate").map(validate_arg).transpose()?,
calculate: a.get("calculate").map(calculate_arg).transpose()?,
@ -410,14 +467,7 @@ impl Automation {
.ok_or_else(|| bad(format!("unknown check style {s:?} (check, circle, cross, diamond, square, star)")))?,
),
},
};
if props == FieldProps::default() {
return Err(ToolError::InvalidArgs("nothing to change".into()));
}
let new_name = props.name.clone().unwrap_or(name.clone());
let mut out = self.apply(a, Edit::SetFieldProps { name, props: Box::new(props) })?;
out["field"] = json!(new_name);
Ok(out)
})
}
pub(crate) fn form_tab_order(&mut self, a: &Args) -> Result<Value> {
@ -470,7 +520,8 @@ impl Automation {
let id = self.doc(a)?.id;
let path = self.resolve(a.str("path")?, true)?;
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
let format = pdfcraft_engine::DataFormat::from_extension(&ext).ok_or_else(|| bad("the path must end in .xfdf, .fdf, .xml, .csv or .txt"))?;
let format =
pdfcraft_engine::DataFormat::from_extension(&ext).ok_or_else(|| bad("the path must end in .xfdf, .fdf, .xml, .xdp, .csv or .txt"))?;
let (comments, fields) = match a.opt_str("what")?.unwrap_or("all") {
"all" => (true, true),
"comments" => (true, false),

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,12 @@
//! Link tools: list, add, edit and delete links; create links from URLs in the text; remove
//! all links. Rectangles are points from the top-left of the displayed page.
use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkStyle};
use pdfcraft_engine::{
Edit, LinkAction, LinkHighlight, LinkStyle, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
};
use pdfcraft_render::{DestView, LinkTarget, RemoteDestination};
use serde_json::{Value, json};
use std::path::Path;
use crate::comments::parse_color;
use crate::{Args, Automation, Result, ToolError, failed};
@ -80,6 +84,72 @@ impl Automation {
Ok(json!({ "count": items.len(), "links": items }))
}
pub(crate) fn link_open(&mut self, a: &Args) -> Result<Value> {
let page_number = a.int("page")?;
let index_number = a.int("index")?;
let page = usize::try_from(page_number.checked_sub(1).filter(|n| *n >= 0).ok_or_else(|| bad("page must be 1 or more"))?)
.map_err(|_| bad("page is out of range"))?;
let index = usize::try_from(index_number.checked_sub(1).filter(|n| *n >= 0).ok_or_else(|| bad("index must be 1 or more"))?)
.map_err(|_| bad("index is out of range"))?;
let (source_path, file, destination, new_window) = {
let source = self.doc(a)?;
if page >= source.info.pages.len() {
return Err(bad(format!("page {page_number} is out of range: the document has {} pages", source.info.pages.len())));
}
let link = source
.info
.links
.iter()
.find(|link| link.page == page && link.index == index)
.ok_or_else(|| failed(format!("page {page_number} has no link {index_number} (see link_list)")))?;
let LinkTarget::RemoteGoTo { file, destination, new_window } = &link.target else {
return Err(bad("the selected link is not a supported local PDF GoToR link"));
};
(source.path.clone(), file.clone(), destination.clone(), *new_window)
};
let path = resolve_remote_document_path(source_path.as_deref().map(Path::new), &file).map_err(failed)?;
let allow_absolute = a.opt_bool("allow_absolute")?.unwrap_or(false);
if remote_document_path_is_absolute(&file) && !allow_absolute {
return Err(failed(
"this GoToR link names an absolute local path; review the target with link_list and pass allow_absolute: true to open it",
));
}
let path = self.resolve(&path.to_string_lossy(), false)?;
let bytes = read_remote_document(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let id =
self.session.open(name, Some(path.to_string_lossy().into_owned()), std::sync::Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
let opened = self.session.get(id).ok_or_else(|| failed("the linked document vanished"))?;
let (destination_applied, destination_value, warning) = match destination {
RemoteDestination::Default => (true, json!({ "kind": "default" }), None),
RemoteDestination::Named(name) => {
(false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied."))
}
RemoteDestination::Page { page, view } if page < opened.info.pages.len() => {
(true, json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }), None)
}
RemoteDestination::Page { page, view } => (
false,
json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }),
Some("The PDF opened, but the link's page destination is out of range."),
),
};
let mut display_path = path.to_string_lossy().into_owned();
if let Some(root) = &self.root
&& let Ok(relative) = path.strip_prefix(root)
{
display_path = relative.to_string_lossy().into_owned();
}
Ok(json!({
"document": crate::summary(opened),
"path": display_path,
"destination_applied": destination_applied,
"destination": destination_value,
"warning": warning,
"new_window_hint": new_window,
}))
}
pub(crate) fn link_add(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let rect = self.view_rect(a, page)?.ok_or_else(|| bad("link_add needs rect"))?;
@ -126,9 +196,27 @@ impl Automation {
}
pub(crate) fn links_remove(&mut self, a: &Args) -> Result<Value> {
let before = self.doc(a)?.links.len();
let mut out = self.apply(a, Edit::RemoveLinks { pages: None })?;
let pages = match a.opt_ints("pages")? {
Some(_) => Some(self.pages(a, "pages")?),
None => None,
};
let before = match &pages {
Some(ps) => self.doc(a)?.links.iter().filter(|l| ps.contains(&l.page)).count(),
None => self.doc(a)?.links.len(),
};
let mut out = self.apply(a, Edit::RemoveLinks { pages })?;
out["removed"] = json!(before);
Ok(out)
}
}
fn dest_view_value(view: DestView) -> Value {
match view {
DestView::Top => json!({ "kind": "top" }),
DestView::Xyz { left, top, zoom } => json!({ "kind": "xyz", "left": left, "top": top, "zoom": zoom }),
DestView::Fit => json!({ "kind": "fit" }),
DestView::FitH { top } => json!({ "kind": "fit_h", "top": top }),
DestView::FitV { left } => json!({ "kind": "fit_v", "left": left }),
DestView::FitR { rect } => json!({ "kind": "fit_r", "rect": rect }),
}
}

View File

@ -329,7 +329,7 @@ fn guarded_tool(f: impl FnOnce() -> Result<Value, (i64, String)>) -> Result<Valu
}
fn tool_content(name: &str, content: Vec<Content>) -> Value {
let failed = name == "command_batch"
let failed = matches!(name, "command_batch" | "doc_batch")
&& content.iter().any(|c| match c {
Content::Json(v) => v.get("failed").and_then(Value::as_u64).is_some_and(|n| n > 0),
_ => false,

View File

@ -12,7 +12,16 @@ fn bad(m: impl Into<String>) -> ToolError {
impl Automation {
pub(crate) fn printers(&self) -> Result<Value> {
let list: Vec<Value> = spool::printers().into_iter().map(|p| json!({ "name": p.name, "default": p.default })).collect();
let list: Vec<Value> = spool::printers()
.into_iter()
.map(|p| {
let mut v = json!({ "name": p.name, "default": p.default });
if let Some(two_sided) = p.two_sided {
v["two_sided"] = json!(two_sided);
}
v
})
.collect();
Ok(json!({ "count": list.len(), "printers": list }))
}
@ -56,9 +65,10 @@ impl Automation {
o => return Err(bad(format!("unknown order {o:?}"))),
};
match Layout::multiple(per) {
Layout::Multiple { cols, rows, .. } => Layout::Multiple {
Layout::Multiple { cols, rows, per_sheet, .. } => Layout::Multiple {
cols,
rows,
per_sheet,
order,
border: a.opt_bool("border")?.unwrap_or(false),
auto_rotate: a.opt_bool("auto_rotate")?.unwrap_or(true),
@ -105,9 +115,15 @@ impl Automation {
None => PAPERS[0].1,
Some(p) => PAPERS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(p) || n.replace("US ", "").eq_ignore_ascii_case(p))
.find(|(n, _)| {
n.eq_ignore_ascii_case(p)
|| n.replace("US ", "").eq_ignore_ascii_case(p)
|| n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p))
})
.map(|x| x.1)
.ok_or_else(|| bad(format!("unknown paper {p:?} (Letter, Legal, Tabloid, A3, A4, A5)")))?,
.ok_or_else(|| {
bad(format!("unknown paper {p:?}; choose one of {}", PAPERS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")))
})?,
};
let settings = print::Settings { pages, paper, orientation, layout, content };
let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect();
@ -126,12 +142,13 @@ impl Automation {
printer: if printer == "default" { None } else { Some(printer.to_string()) },
copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32,
collate: a.opt_bool("collate")?.unwrap_or(true),
duplex: match a.opt_str("duplex")?.unwrap_or("off") {
duplex: Some(match a.opt_str("duplex")?.unwrap_or("off") {
"off" => spool::Duplex::Off,
"long-edge" => spool::Duplex::LongEdge,
"short-edge" => spool::Duplex::ShortEdge,
d => return Err(bad(format!("unknown duplex {d:?}"))),
},
}),
scaling: spool::Scaling::Exact,
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
title: name,
options: match a.get("options") {

View File

@ -0,0 +1,36 @@
//! Reading colours: Acrobat's Replace Document Colors as a tool (#897, AGENTS.md §3).
//!
//! A per-document view setting: solid text and line-art paints remap for readability and the
//! page clears to the setting's background when rendering. The file itself never changes.
use pdfcraft_render::ReadingColors;
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError};
/// The modes `ReadingColors::parse` accepts, for error messages.
const MODES: &str = "off, black-on-white, white-on-black, yellow-on-black, green-on-black, custom";
impl Automation {
/// Set the document's reading colours. Colours are only meaningful with `mode: "custom"`;
/// `parse` enforces the rest and its errors are actionable.
pub(crate) fn view_reading_colors(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let Some(mode) = a.opt_str("mode")? else {
return Err(ToolError::InvalidArgs(format!("mode is required (one of: {MODES})")));
};
let colors = ReadingColors::parse(mode, a.opt_str("text")?, a.opt_str("background")?, a.opt_bool("only_black")?, a.opt_bool("line_art")?)
.map_err(ToolError::InvalidArgs)?;
if self.session.set_reading_colors(id, colors) {
// Cached renderers baked the old setting into their config; page_render must re-read it.
self.renderers.remove(&id);
}
Ok(json!({
"mode": colors.mode_name(),
"text": colors.text_hex(),
"background": colors.background_hex(),
"only_black": colors.params.is_some_and(|p| p.only_black),
"line_art": colors.params.is_some_and(|p| p.line_art),
}))
}
}

View File

@ -1,7 +1,10 @@
//! Redaction tools: mark areas, text, patterns or whole pages for redaction, apply the marks
//! (removing what they cover for good) or clear them.
use pdfcraft_engine::{Edit, Hidden, NewAnnotation, RedactPattern, Shape, Style, find_pattern, rect_quad};
use pdfcraft_engine::{
Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACTION_CODE_SETS, RedactPattern, RedactionCodeSet, Shape, Style, find_pattern, rect_quad,
redact_word_list,
};
use serde_json::{Value, json};
use crate::comments::{DEFAULT_AUTHOR, parse_color};
@ -15,7 +18,25 @@ impl Automation {
Some(_) => self.pages(a, "pages")?,
None => (0..n).collect(),
};
let overlay = a.opt_str("overlay")?.unwrap_or("").to_string();
let overlay = match (a.opt_str("overlay")?, a.opt_str("code_set")?) {
(Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass overlay or code_set with codes, not both".into())),
(Some(o), None) => o.to_string(),
(None, Some(id)) => {
let set = RedactionCodeSet::from_id(id).ok_or_else(|| {
let ids: Vec<_> = REDACTION_CODE_SETS.iter().map(|s| s.id).collect();
ToolError::InvalidArgs(format!("unknown code_set {id:?}; allowed: {}", ids.join(", ")))
})?;
let picked = a.strs("codes")?;
let allowed = || format!("allowed in {}: {}", set.id, set.codes.join(", "));
match set.overlay(&picked) {
Ok(o) if !o.is_empty() => o,
Ok(_) => return Err(ToolError::InvalidArgs(format!("codes is empty; {}", allowed()))),
Err(bad) => return Err(ToolError::InvalidArgs(format!("unknown code {bad:?}; {}", allowed()))),
}
}
(None, None) if a.get("codes").is_some() => return Err(ToolError::InvalidArgs("codes needs code_set".into())),
(None, None) => String::new(),
};
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
let mut style = Style::default_for(&Shape::Redact { quads: Vec::new(), overlay: String::new(), look: Default::default() });
if let Some(c) = a.opt_str("fill")? {
@ -23,10 +44,23 @@ impl Automation {
}
// (page, quads) per mark.
let mut marks: Vec<(usize, Vec<[f64; 8]>)> = Vec::new();
let chosen = [a.get("rect").is_some(), a.get("find").is_some(), a.get("pattern").is_some(), a.opt_bool("whole_pages")?.unwrap_or(false)];
let chosen = [
a.get("rect").is_some(),
a.get("find").is_some(),
a.get("words").is_some(),
a.get("pattern").is_some(),
a.opt_bool("whole_pages")?.unwrap_or(false),
];
if chosen.iter().filter(|c| **c).count() != 1 {
return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, pattern or whole_pages: true".into()));
return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, words, pattern or whole_pages: true".into()));
}
let case_sensitive = a.opt_bool("case_sensitive")?.unwrap_or(false);
let whole_words = a.opt_bool("whole_words")?.unwrap_or(false);
if (case_sensitive || whole_words) && a.get("find").is_none() && a.get("words").is_none() {
return Err(ToolError::InvalidArgs("case_sensitive and whole_words apply only to find or words".into()));
}
// Per searched word or phrase: how many marks it made.
let mut matched_words: Vec<(String, usize)> = Vec::new();
if a.get("rect").is_some() {
let page = self.page(a)?;
let info = &self.doc(a)?.info.pages[page];
@ -41,23 +75,44 @@ impl Automation {
marks.push((p, vec![rect_quad([c[0] as f64, c[1] as f64, c[2] as f64, c[3] as f64])]));
}
} else {
let find = a.opt_str("find")?.map(str::to_owned);
let pattern = match a.opt_str("pattern")? {
Some(p) => Some(RedactPattern::from_id(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown pattern {p:?}")))?),
None => None,
};
let needles: Vec<String> = match (a.opt_str("find")?, a.get("words")) {
(Some(f), _) => vec![f.to_owned()],
(None, Some(_)) => {
let raw = a.strs("words")?;
if raw.len() > REDACT_MAX_WORDS {
return Err(ToolError::InvalidArgs(format!("words has {} entries; at most {REDACT_MAX_WORDS} are allowed", raw.len())));
}
let list = redact_word_list(&raw.join("\n"));
if list.is_empty() {
return Err(ToolError::InvalidArgs("words must hold at least one non-empty word or phrase".into()));
}
list
}
(None, None) => Vec::new(),
};
matched_words = needles.iter().map(|w| (w.clone(), 0)).collect();
let texts = self.page_texts(id, &pages)?;
let info = self.doc(a)?.info.clone();
for (&p, text) in pages.iter().zip(&texts) {
let hits = match (&find, pattern) {
(Some(f), _) => text.find(f),
(None, Some(pat)) => text.find_with(|chars| find_pattern(pat, chars)),
_ => Vec::new(),
let hits: Vec<(Option<usize>, _)> = match pattern {
Some(pat) => text.find_with(|chars| find_pattern(pat, chars)).into_iter().map(|h| (None, h)).collect(),
None => needles
.iter()
.enumerate()
.flat_map(|(i, w)| text.find_opts(w, case_sensitive, whole_words).into_iter().map(move |h| (Some(i), h)))
.collect(),
};
for h in hits {
for (word, h) in hits {
let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| info.pages[p].view_rect_to_quad(r)).collect();
if !quads.is_empty() {
marks.push((p, quads));
if let Some(m) = word.and_then(|i| matched_words.get_mut(i)) {
m.1 += 1;
}
}
}
}
@ -81,6 +136,9 @@ impl Automation {
out["marked"] = json!(count);
out["pages_marked"] = json!(marks.iter().map(|m| m.0 + 1).collect::<std::collections::BTreeSet<_>>());
out["marks_pending"] = json!(self.doc(a)?.redaction_marks());
if a.get("words").is_some() {
out["matched_words"] = json!(matched_words.iter().map(|(w, n)| json!({ "word": w, "marked": n })).collect::<Vec<_>>());
}
Ok(out)
}
@ -114,6 +172,22 @@ impl Automation {
Ok(json!({ "total": total, "categories": items }))
}
pub(crate) fn doc_audit_redactions(&self, a: &Args) -> Result<Value> {
let findings = self.doc(a)?.audit_redactions().map_err(ToolError::Failed)?;
let items: Vec<Value> = findings
.into_iter()
.map(|f| {
json!({
"page": f.page + 1,
"kind": f.kind.id(),
"rect": f.rect,
"covered_text": f.covered_text,
})
})
.collect();
Ok(json!({ "findings": items, "count": items.len() }))
}
pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result<Value> {
let edit = match a.get("categories") {
None => Edit::Sanitize,

View File

@ -70,7 +70,8 @@ impl Automation {
"timestamp_time": s.timestamp_time.map(|t| t.to_string()),
"modification": modification,
"changes": changes,
"details": s.details,
// Scripts read this, so the sentences stay in English, whatever the interface reads in.
"details": s.details.iter().map(|d| d.message.clone()).collect::<Vec<_>>(),
}))
}
@ -171,6 +172,11 @@ impl Automation {
}),
Some(v) => Some(v.as_u64().filter(|n| (1..=3).contains(n)).ok_or_else(|| bad("certify must be 1, 2 or 3"))? as u8),
};
let graphic = match a.get("graphic") {
None | Some(Value::Null) => None,
Some(g) if rect.is_none() && field.is_none() => return Err(bad(format!("graphic {g} needs a visible signature: pass rect or field"))),
Some(g) => Some(self.signature_graphic(g)?),
};
let out = self.resolve(a.str("out")?, true)?;
let opts = SignOptions {
field,
@ -180,9 +186,14 @@ impl Automation {
location: a.opt_str("location")?.map(str::to_string),
contact: a.opt_str("contact")?.map(str::to_string),
certify,
graphic,
..SignOptions::default()
};
let signed = self.session.sign(doc_id, &id, opts).map_err(failed)?;
let signed = self.session.sign(doc_id, &id, opts).map_err(|e| match e {
// A graphic the signer refuses is the caller's to fix.
pdfcraft_engine::EditError::Sign(m) if m.starts_with("the signature graphic") => bad(m),
e => failed(e),
})?;
write_atomic(&out, &signed)?;
let path = out.to_string_lossy().into_owned();
self.session.mark_signed(doc_id, signed.clone(), Some(path.clone())).map_err(failed)?;
@ -191,6 +202,41 @@ impl Automation {
Ok(json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? }))
}
/// `{"image": path}` (PNG or JPEG), `{"text": name}` (typed in the script font) or
/// `{"strokes": [[[x, y], …], …]}` (y up; the drawing pad is 1 wide).
fn signature_graphic(&self, g: &Value) -> Result<sign::Graphic> {
let kinds = "graphic must be an object with exactly one of image (a PNG or JPEG path), text, or strokes";
let Some(o) = g.as_object().filter(|o| o.len() == 1) else { return Err(bad(kinds)) };
if let Some(path) = o.get("image") {
let path = self.resolve(path.as_str().ok_or_else(|| bad("graphic.image must be a path"))?, false)?;
let file = std::fs::File::open(&path).map_err(|e| bad(format!("graphic.image {}: {e}", path.display())))?;
return Ok(pdfcraft_engine::SignatureImage::read(file).map_err(|e| bad(e.to_string()))?.graphic());
}
if let Some(text) = o.get("text") {
let text = text.as_str().ok_or_else(|| bad("graphic.text must be a string"))?;
// Nobody sees a preview here: refuse what the script font and its fallback would leave out.
if let Some(c) = pdfcraft_engine::script_missing(text) {
return Err(bad(format!(
"the signature script font can't draw \"{}\" (U+{:04X}) in graphic.text; type it in Latin, Cyrillic or Greek letters, or pass graphic.image instead",
c.escape_debug(),
u32::from(c)
)));
}
return pdfcraft_engine::typed_signature_graphic(text).ok_or_else(|| {
bad(format!(
"graphic.text {text:?} has nothing the script font can draw (at most {} characters)",
pdfcraft_engine::MAX_SIGNATURE_CHARS
))
});
}
if let Some(strokes) = o.get("strokes") {
let strokes: Vec<Vec<[f64; 2]>> =
serde_json::from_value(strokes.clone()).map_err(|e| bad(format!("graphic.strokes must be strokes of [x, y] points: {e}")))?;
return Ok(sign::Graphic::Strokes(strokes));
}
Err(bad(kinds))
}
pub(crate) fn sign_trust(&mut self, a: &Args) -> Result<Value> {
let mut certs: Vec<Certificate> =
if a.opt_bool("clear")?.unwrap_or(false) { Vec::new() } else { self.session.trusted_certificates().to_vec() };

View File

@ -0,0 +1,177 @@
//! Bounded, session-wide page-text reuse. Active tool results own their Arcs independently.
use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Weak};
use pdfcraft_engine::DocId;
use pdfcraft_render::{PageText, TextGlyph};
const BYTE_BUDGET: usize = 64 * 1024 * 1024;
// Also bound bookkeeping for documents with thousands of empty pages.
const PAGE_BUDGET: usize = 4096;
type Key = (DocId, usize);
struct Cached {
version: Weak<Vec<u8>>,
text: Arc<PageText>,
bytes: usize,
stamp: u64,
}
pub(super) struct TextCache {
pages: HashMap<Key, Cached>,
order: BTreeMap<u64, Key>,
clock: u64,
bytes: usize,
budget: usize,
}
impl Default for TextCache {
fn default() -> Self {
Self::new(BYTE_BUDGET)
}
}
impl TextCache {
pub(super) fn new(budget: usize) -> Self {
Self { pages: HashMap::new(), order: BTreeMap::new(), clock: 0, bytes: 0, budget }
}
pub(super) fn prepare(&mut self, id: DocId, bytes: &Arc<Vec<u8>>) {
let version = Arc::downgrade(bytes);
// At most PAGE_BUDGET entries, independent of the document's page count. Weak pointers
// distinguish revisions without keeping an obsolete PDF byte buffer alive.
let stale: Vec<_> = self.pages.iter().filter(|(key, page)| key.0 == id && !page.version.ptr_eq(&version)).map(|(key, _)| *key).collect();
for key in stale {
self.remove_page(key);
}
}
pub(super) fn remove(&mut self, id: &DocId) {
let keys: Vec<_> = self.pages.keys().filter(|key| key.0 == *id).copied().collect();
for key in keys {
self.remove_page(key);
}
}
fn remove_page(&mut self, key: Key) {
if let Some(page) = self.pages.remove(&key) {
self.order.remove(&page.stamp);
self.bytes -= page.bytes;
}
}
fn stamp(&mut self) -> u64 {
if self.clock == u64::MAX {
self.pages.clear();
self.order.clear();
self.bytes = 0;
self.clock = 0;
}
self.clock += 1;
self.clock
}
pub(super) fn get(&mut self, id: DocId, page: usize) -> Option<Arc<PageText>> {
let stamp = self.stamp();
let entry = self.pages.get_mut(&(id, page))?;
self.order.remove(&entry.stamp);
entry.stamp = stamp;
self.order.insert(stamp, (id, page));
Some(entry.text.clone())
}
pub(super) fn insert(&mut self, id: DocId, page: usize, version: &Arc<Vec<u8>>, text: Arc<PageText>) {
let bytes = retained_bytes(&text);
self.remove_page((id, page));
// An oversized page is usable by this call, but never retained by the cache.
if bytes > self.budget {
return;
}
while self.bytes > self.budget - bytes || self.pages.len() >= PAGE_BUDGET {
let Some((_, key)) = self.order.first_key_value() else { break };
self.remove_page(*key);
}
let stamp = self.stamp();
self.pages.insert((id, page), Cached { version: Arc::downgrade(version), text, bytes, stamp });
self.order.insert(stamp, (id, page));
self.bytes += bytes;
}
#[cfg(test)]
pub(super) fn len(&self) -> usize {
self.pages.len()
}
#[cfg(test)]
pub(super) fn bytes(&self) -> usize {
self.bytes
}
}
// Estimate owned allocations, including spare capacity; bookkeeping has its own page-count cap.
fn retained_bytes(text: &PageText) -> usize {
let arrays = std::mem::size_of::<PageText>()
.saturating_add(2 * std::mem::size_of::<usize>())
.saturating_add(text.glyphs.capacity().saturating_mul(std::mem::size_of::<TextGlyph>()))
.saturating_add(text.line_of.capacity().saturating_mul(std::mem::size_of::<u32>()))
.saturating_add(text.space_before.capacity().saturating_mul(std::mem::size_of::<bool>()));
text.glyphs.iter().fold(arrays, |sum, glyph| sum.saturating_add(glyph.text.capacity()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn budget_counts_owned_capacity_and_lru_is_shared_across_documents() {
let mut text = PageText::default();
text.glyphs.reserve(32);
text.line_of.reserve(48);
text.space_before.reserve(64);
let mut word = String::with_capacity(1000);
word.push('a');
text.glyphs.push(TextGlyph { text: word, rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] });
let cost = retained_bytes(&text);
assert!(cost >= 32 * std::mem::size_of::<TextGlyph>() + 48 * 4 + 64 + 1000);
let text = Arc::new(text);
let version = Arc::new(vec![1]);
let mut cache = TextCache::new(cost * 2);
cache.insert(DocId(1), 0, &version, text.clone());
cache.insert(DocId(2), 0, &version, text.clone());
assert!(Arc::ptr_eq(&cache.get(DocId(1), 0).unwrap(), &text));
cache.insert(DocId(3), 0, &version, text.clone());
assert!(cache.get(DocId(2), 0).is_none(), "least recently used page in another document is evicted");
assert_eq!(cache.bytes, 2 * cost);
cache.insert(DocId(1), 0, &version, text.clone());
assert_eq!(cache.bytes, 2 * cost, "replacement does not double-count");
let next_version = Arc::new(vec![1]);
cache.prepare(DocId(1), &next_version);
assert!(cache.get(DocId(1), 0).is_none(), "equal bytes in a new Arc are a new version");
assert_eq!(cache.bytes, cost);
cache.remove(&DocId(3));
assert_eq!((cache.bytes, cache.len(), cache.order.len()), (0, 0, 0));
assert_eq!(Arc::strong_count(&version), 1, "the cache never retains a PDF byte buffer");
}
#[test]
fn oversized_pages_bypass_cache_and_empty_pages_have_a_bookkeeping_limit() {
let text = Arc::new(PageText::default());
let version = Arc::new(vec![]);
let cost = retained_bytes(&text);
let mut tiny = TextCache::new(cost - 1);
tiny.insert(DocId(1), 0, &version, text.clone());
assert_eq!((tiny.bytes, tiny.len()), (0, 0));
let mut cache = TextCache::default();
for page in 0..PAGE_BUDGET + 50 {
cache.insert(DocId(1), page, &version, text.clone());
}
assert_eq!(cache.len(), PAGE_BUDGET);
assert!(cache.get(DocId(1), 0).is_none());
assert!(cache.get(DocId(1), PAGE_BUDGET + 49).is_some());
assert_eq!(cache.bytes, PAGE_BUDGET * cost);
cache.clock = u64::MAX;
cache.insert(DocId(2), 0, &version, text);
assert_eq!((cache.bytes, cache.len()), (cost, 1), "recency counter rollover safely clears old state");
}
}

View File

@ -118,6 +118,17 @@ pub fn tools() -> Vec<ToolDef> {
let save_out = json!({ "type": "string", "description": "File to write. Omit to open the result as a new unsaved document instead." });
let open = json!({ "type": "boolean", "description": "Also open the result as a new document (default: only when out is omitted)." });
vec![
t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.")
.with(schema(json!({
"paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000},
"out_dir": {"type":"string"},
"steps": {"type":"array","items":{"type":"string","enum":["convert","ocr","optimize"]},"minItems":1,"maxItems":3,"uniqueItems":true},
"recursive": {"type":"boolean"}, "stop_on_error": {"type":"boolean"},
"ppi": {"type":"number","minimum":9,"maximum":1600},
"quality": {"type":"integer","minimum":1,"maximum":100},
"dpi": {"type":"number","minimum":72,"maximum":600},
"language": {"type":"string"}, "skip_text_pages": {"type":"boolean"}
}), &["paths","out_dir","steps"])),
t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.")
.cmd("file.open")
.with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])),
@ -145,18 +156,37 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords. Undoable.")
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords; an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.")
.with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])),
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 } }), &["doc", "page"])),
t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order.")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 },
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." }
}), &["doc", "page"])),
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Clipping and vertical text are refused. A bad/stale reference refuses the entire move.")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},
"objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
"offset":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2},
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","objects","offset"])),
t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab.")
.ro()
.with(schema(json!({ "doc": doc(), "pages": pages("to extract (default: all)") }), &["doc"])),
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return each match with its page and line rectangles in points (origin top-left).")
.cmd("edit.column_select")
.with(schema(
json!({
"doc": doc(), "pages": pages("to extract (default: all)"),
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Column select: [x0, y0, x1, y1] in points from the top-left of the displayed page; a glyph counts when its centre is inside." }
}),
&["doc"],
)),
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return matches in page order with page and line rectangles in points (origin top-left). Reads growing batches, looking one match past the limit for a continuation. Failed pages do not discard readable matches: warnings contains up to 100 {page, error, error_truncated} entries (errors capped at 1024 UTF-8 bytes), warning_count counts failed pages, and warnings_truncated reports a capped list. complete is false after a page failure or when more matches exist; next is present only for a match-limit continuation.")
.ro()
.cmd("edit.find")
.with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500)." } }), &["doc", "query"])),
.with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500). When another match exists, next holds start_page and skip to continue. complete is false for either additional matches or failed pages; an exhausted search with warnings has no next." },
"start_page": { "type": "integer", "minimum": 1, "description": "Page to start searching from (default 1)." },
"skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." } }), &["doc", "query"])),
t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise): the listed pages (default all), filtered like Acrobat's Rotate Pages by subset (all, even, odd page numbers) and orientation (all, landscape, portrait). Undoable.")
.cmd("page.rotate")
.with(schema(
@ -167,6 +197,18 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "degrees"],
)),
t("page_enhance_camera", "Enhance camera image", "Straighten a photographed document (Scan & OCR ▸ Enhance ▸ Camera image): the part of the page inside the four corners (x1, y1, … x4, y4 in top-left-origin points, any order, a convex quadrilateral; when omitted, the document's edges are found automatically, or an error says they could not be) is warped to an upright rectangle that replaces what the page shows, at the page picture's own resolution (at most 600 dpi). mode is how it looks: original, colour (default), greyscale or bw (black and white, stored losslessly). level (0–100, default 0) then cleans the photo up: even lighting so the paper turns white (casts, shadows, dark edges), more contrast, and from 60 no isolated specks; colour ink stays colour. For bw the level is the threshold (higher keeps fainter strokes black). The page's comments, links and bookmarks stay. Undoable.")
.cmd("ocr.enhance_camera")
.with(schema(
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"corners": { "type": "array", "items": { "type": "number" }, "minItems": 8, "maxItems": 8 },
"level": { "type": "integer", "minimum": 0, "maximum": 100 },
"mode": { "type": "string", "enum": ["original", "colour", "greyscale", "bw"] },
}),
&["doc", "page"],
)),
t("page_delete", "Delete pages", "Delete pages. Undoable until saved.")
.destructive()
.cmd("page.delete")
@ -190,13 +232,15 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }),
&["doc", "pages"],
)),
t("doc_combine", "Combine files", "Combine PDFs, in order, into one (bookmarks are kept under one entry per file). pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back.")
t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.")
.cmd("page.combine")
.with(schema(
json!({
"paths": { "type": "array", "items": { "type": "string" }, "minItems": 2 },
"pages": { "type": "array", "items": { "type": ["string", "null"] } },
"passwords": { "type": "array", "items": { "type": ["string", "null"] } },
"groups": { "type": "array", "items": { "type": ["integer", "null"], "minimum": 0 } },
"field_conflicts": { "type": "string", "enum": ["rename", "merge"] },
"out": save_out,
"open": open,
}),
@ -206,7 +250,7 @@ pub fn tools() -> Vec<ToolDef> {
.cmd("create.multiple")
.with(schema(
json!({
"paths": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": pdfcraft_engine::MAX_CREATE_FILES },
"paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
"mode": { "type": "string", "enum": ["combine", "separate"] },
"pages": { "type": "array", "items": { "type": ["string", "null"] } },
"out": save_out.clone(),
@ -215,15 +259,26 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["paths"],
)),
t("doc_split", "Split a document", "Split into several files written to out_dir: every N pages, before given pages, at top-level bookmarks (bookmarks: true; files named after them), or by file size (max_mb). Files are <name>-partK.pdf.")
t("doc_split", "Split a document", "Split into several files written to out_dir: every N pages, before given pages, at top-level bookmarks (bookmarks: true; files named after them, repeated titles get -2, -3), or by file size (max_mb). Files are <name>-partK.pdf.")
.cmd("page.split")
.with(schema(
json!({ "doc": doc(), "every": { "type": "integer", "minimum": 1 }, "before": pages("that start a new part"), "bookmarks": { "type": "boolean" }, "max_mb": { "type": "number", "exclusiveMinimum": 0 }, "out_dir": { "type": "string" } }),
&["doc", "out_dir"],
)),
t("bookmark_list", "List bookmarks", "The bookmark tree with each bookmark's path, title, target page and open state.")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
"bookmark_list",
"List bookmarks",
"The bookmarks in outline order, one page at a time: each with its path (1-based, as the other bookmark tools take it), title, target page and open state, and its children on the same page. next is the offset of the following page, or null at the end. Bookmarks nested deeper than 33 levels are not listed.",
)
.ro()
.with(schema(
json!({
"doc": doc(),
"offset": { "type": "integer", "minimum": 0, "description": "Where the page starts, counted from 0 (the previous page's next)" },
"limit": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Bookmarks in the page (default 100)" }
}),
&["doc"],
)),
t("bookmark_add", "Add a bookmark", "Add a bookmark that goes to a page, under a parent bookmark (or at the top level), at a position. Undoable.").with(schema(
json!({ "doc": doc(), "title": { "type": "string", "minLength": 1 }, "page": { "type": "integer", "minimum": 1 }, "parent": path("Parent bookmark (omit for the top level)"), "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." } }),
&["doc", "title", "page"],
@ -239,6 +294,12 @@ pub fn tools() -> Vec<ToolDef> {
)),
t("bookmark_set_page", "Set a bookmark's page", "Point a bookmark at another page. Undoable.")
.with(schema(json!({ "doc": doc(), "path": path("The bookmark"), "page": { "type": "integer", "minimum": 1 } }), &["doc", "path", "page"])),
t(
"bookmark_from_structure",
"New bookmarks from structure",
"Make bookmarks from the document's tagged headings (H, H1-H6), nested by level under a new first bookmark titled \"Untitled\". Fails if the document has no tagged headings. Returns the first page of the bookmark tree (see bookmark_list). Undoable.",
)
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("page_number", "Number pages", "Label a range of pages (e.g. i, ii, iii for front matter, or A-1, A-2 for an appendix). Later pages keep their labels. Undoable.").with(schema(
json!({
"doc": doc(),
@ -256,7 +317,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"form_fill",
"Fill in form fields",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values.",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
)
.with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])),
t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.")
@ -298,17 +359,19 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "page", "path", "at"],
)),
t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). Undoable.")
t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.")
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])),
t(
"form_set_props",
"Field properties",
"Change a field's properties (General and Options tabs): name (renames it), tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), rect (position), rotation (0, 90, 180 or 270 degrees counterclockwise; changing between horizontal and vertical swaps width and height around the center), format, validate, calculate (Acrobat's Format/Validate/Calculate tabs, run natively: values are checked, formatted and recalculated as in Acrobat). Options tab: align (left, center, right), default (the value Reset form restores; for check boxes and radio buttons their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes and radio buttons: check, circle, cross, diamond, square, star). Only the given ones change. Undoable.",
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position) and rotation require a single field. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
)
.with(schema(
.with({
let mut input = schema(
json!({
"doc": doc(),
"field": { "type": "string", "description": "The field's current name." },
"fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." },
"name": { "type": "string" },
"tooltip": { "type": "string" },
"read_only": { "type": "boolean" },
@ -324,22 +387,25 @@ pub fn tools() -> Vec<ToolDef> {
"font_size": { "type": "number", "minimum": 0 },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Move/resize: points from the top-left of the displayed page." },
"rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." },
"appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." },
"appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." },
"format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." },
"validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out) or \"none\"." },
"calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." },
}),
&["doc", "field"],
)),
&["doc"],
);
input["oneOf"] = json!([{ "required": ["field"] }, { "required": ["fields"] }]);
input
}),
t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Returns the resulting order of fields. Undoable.")
.with(schema(
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
&["doc"],
)),
t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). what: all (default), comments, fields.")
t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.")
.destructive()
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])),
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. Undoable.")
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.")
.destructive()
@ -347,7 +413,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"redact_mark",
"Mark for redaction",
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find, pattern and whole_pages work on pages (default all). overlay: text shown on the box once applied; fill: box colour (default black). Undoable.",
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). find, words, pattern and whole_pages work on pages (default all). overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
)
.with(schema(
json!({
@ -355,10 +421,15 @@ pub fn tools() -> Vec<ToolDef> {
"page": { "type": "integer", "minimum": 1 },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"find": { "type": "string", "minLength": 1 },
"words": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "maxItems": 1000 },
"case_sensitive": { "type": "boolean", "description": "Match letter case for find or words (default false)." },
"whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." },
"pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] },
"whole_pages": { "type": "boolean" },
"pages": pages("to search or mark (default: all)"),
"overlay": { "type": "string" },
"code_set": { "type": "string", "enum": ["foia", "privacy-act"] },
"codes": { "type": "array", "items": { "type": "string" }, "minItems": 1, "description": "Redaction codes from code_set, e.g. [\"(b)(6)\"]." },
"fill": { "type": "string", "description": "#RRGGBB or a colour name." },
"author": { "type": "string" },
}),
@ -394,6 +465,13 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t(
"doc_audit_redactions",
"Audit faux redactions",
"Find faux redactions: opaque filled rectangles painted over text that is still extractable, and /Redact annotations that were marked but never applied. Paint-order and colour-aware: a rectangle counts only when painted after the text and dark enough to hide it (page backgrounds and light shading are not flagged). Each finding reports the 1-based page, the kind (covered-text or unapplied-mark), the rectangle, and the hidden text. Fails closed: unreadable content is an error, never a clean bill. A properly applied redaction removes the text and never flags.",
)
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("printers", "List printers", "The printers the system's print spooler knows (CUPS on macOS and Linux), with the default marked.").ro().with(schema(json!({}), &[])),
t("printer_options", "List printer options", "A printer driver's own job options (CUPS: from its PPD), such as the paper tray, paper type or finishing: key, label, group, default and choices. Pass chosen values to doc_print as options. Empty on Windows, where the driver's preferences window holds them.")
.ro()
@ -401,7 +479,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"doc_print",
"Print",
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid/A3/A4/A5. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, or A0/A1/A2/A3/A4/A5 (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
)
.with(schema(
json!({
@ -438,7 +516,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"page_add_text",
"Add text to a page",
"Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. It stays editable with content_update. Undoable.",
"Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters; others are refused. It stays editable with content_update. Undoable.",
)
.cmd("edit.text")
.with(schema(
@ -483,6 +561,11 @@ pub fn tools() -> Vec<ToolDef> {
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url or to_page).")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }),
&["doc", "page", "index"],
)),
t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.")
.cmd("edit.link")
.with(schema(
@ -509,10 +592,10 @@ pub fn tools() -> Vec<ToolDef> {
t("links_from_urls", "Create links from URLs", "Find web addresses (http://, https://, www.) in the text of every page and make them clickable links. Returns the URLs. Undoable.")
.cmd("edit.links_from_urls")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("links_remove", "Remove all links", "Remove every link in the document. Undoable.")
t("links_remove", "Remove all links", "Remove every link in the document (or on the given pages). Undoable.")
.destructive()
.cmd("edit.remove_links")
.with(schema(json!({ "doc": doc() }), &["doc"])),
.with(schema(json!({ "doc": doc(), "pages": pages("whose links to remove (default: all)") }), &["doc"])),
t("measure_distance", "Measure distance", "Add an undoable two-point distance annotation using the scale of the first point's viewport.")
.cmd("measure.distance").with(measure_schema()),
t("measure_perimeter", "Measure perimeter", "Add an undoable connected-line length annotation. To include a closing edge, repeat the first point at the end.")
@ -538,7 +621,7 @@ pub fn tools() -> Vec<ToolDef> {
"Add a comment as Acrobat's commenting tools do. Geometry is in points with the origin at the top-left of the displayed page, y down (as in page_render images at 72 dpi and text_find rects). \
note: `at` [x, y] (icon top-left). stamp: `at` [x, y] (its centre) and `stamp`: approved, completed, confidential, draft, final, for comment, for public release, information only, not approved, not for public release, preliminary results, void, accepted, initial here, rejected, sign here, witness; dynamic: true for the dynamic approved/confidential/received/reviewed/revised stamps with a By … at … line. highlight/underline/strikeout/squiggly: `find` (text on the page to mark; every match with all: true) or `quads`. \
rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. Optional `endings` (two names: start, end) for a line, arrow or polyline, or one name for a callout: None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash. An arrow without endings is None then OpenArrow. ink: `strokes` [[[x, y], …], …]. \
polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable.",
polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable. Text a text box, callout or stamp draws in the standard fonts that they can't draw (Chinese, Greek) is saved as typed and drawn as `?`; `warnings` in the result says so.",
)
.with(schema(
json!({
@ -595,7 +678,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["name", "password", "path"],
)),
t("sign_windows_ids", "List Windows store digital IDs", "Windows: signing identities in the Current User Personal certificate store (certificate details and the windows: reference sign_document takes). Private keys remain in CNG; Windows may ask permission to use them.")
t("sign_windows_ids", "List Windows store digital IDs", "Windows: signing identities in the Current User Personal certificate store (certificate details and the windows: reference sign_document takes). Private keys remain in CNG; Windows may ask permission to use them. `unusable` lists the store's other certificates with why they can't sign (no private key, an unsupported key type, a key CNG can't open).")
.ro()
.cmd("sign.digital")
.with(schema(json!({}), &[])),
@ -603,7 +686,7 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("sign.digital")
.with(schema(json!({}), &[])),
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
.cmd("sign.digital")
.with(schema(
json!({
@ -617,6 +700,17 @@ pub fn tools() -> Vec<ToolDef> {
"location": { "type": "string" },
"contact": { "type": "string" },
"certify": { "type": "string", "enum": ["no_changes", "form_fill", "comments"] },
"graphic": {
"type": "object",
"description": "Exactly one of image, text or strokes.",
"properties": {
"image": { "type": "string", "description": "A PNG or JPEG file." },
"text": { "type": "string", "description": "Typed in the script font." },
"strokes": { "type": "array", "items": { "type": "array", "items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 } } },
},
"minProperties": 1,
"maxProperties": 1,
},
"out": { "type": "string", "description": "Where to save the signed document." },
}),
&["doc", "id", "out"],
@ -636,18 +730,32 @@ pub fn tools() -> Vec<ToolDef> {
t("comments_hide", "Hide all comments", "Hide or show every comment on the page (fields and links still draw). A view setting: the file doesn't change.")
.cmd("comment.hide_all")
.with(schema(json!({ "doc": doc(), "hidden": { "type": "boolean", "description": "Default true." } }), &["doc"])),
t("view_reading_colors", "Set reading colours", "Set the document's reading colours (Acrobat's Replace Document Colors, in the Reading Options): solid text and line-art paints are remapped for readability and the page clears to the background colour in page_render. A view setting: the file doesn't change. mode is one of: off, black-on-white, white-on-black, yellow-on-black, green-on-black, or custom (which takes text and background as #RRGGBB). only_black maps only near-black paints (default false) and line_art also maps line-art paints (default true); both override the mode's preset. A flag that differs from the mode's preset default makes the returned mode read custom, because the resulting params no longer match any preset. Returns the resulting setting.")
.cmd("a11y.reading_options")
.with(schema(
json!({
"doc": doc(),
"mode": { "type": "string", "enum": ["off", "black-on-white", "white-on-black", "yellow-on-black", "green-on-black", "custom"] },
"text": { "type": "string", "description": "With mode custom: the text colour, #RRGGBB." },
"background": { "type": "string", "description": "With mode custom: the background colour, #RRGGBB." },
"only_black": { "type": "boolean", "description": "Map only near-black paints. Default false." },
"line_art": { "type": "boolean", "description": "Also map line-art paints. Default true." },
}),
&["doc", "mode"],
)),
t("comments_summarize", "Summarize comments", "Make a PDF summarising every comment (number, author, type, date, text, replies), sorted by page, author, date or type.")
.cmd("comment.summarize")
.with(schema(
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
&["doc"],
)),
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, fill (rectangle/oval/polygon; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
comment_ref(json!({
"contents": { "type": "string" },
"color": color(),
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
"width": { "type": "number", "minimum": 0 },
"fill": { "type": "string", "description": "Interior colour of a rectangle, oval or polygon: #RRGGBB, a colour name, or \"none\" for no fill." },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"move": point(),
})),
@ -722,7 +830,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given, on top unless behind: true. replace: true swaps out existing ones. Undoable.")
t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given, on top unless behind: true; moved from the page centre by `offset_x` (right) and `offset_y` (up) points (default 0). replace: true swaps out existing ones. Undoable.")
.cmd("edit.watermark")
.with(schema(
json!({
@ -737,6 +845,8 @@ pub fn tools() -> Vec<ToolDef> {
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
"rotation": { "type": "number" },
"behind": { "type": "boolean" },
"offset_x": { "type": "number", "description": "Points right of the page centre (negative: left)." },
"offset_y": { "type": "number", "description": "Points above the page centre (negative: below)." },
"replace": { "type": "boolean" },
}),
&["doc"],
@ -750,7 +860,7 @@ pub fn tools() -> Vec<ToolDef> {
t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])),
t("doc_export_images", "Export pages as images", "Write pages as PNG, JPEG or TIFF files (`<name>_page_<n>.png|jpg|tif`) into a folder, at a resolution (default 150 dpi). JPEG and TIFF are flattened onto white paper. Includes unsaved edits.")
t("doc_export_images", "Export pages as images", "Write pages as PNG, JPEG or TIFF files (`<name>_page_<n>.png|jpg|tif`) into a folder, at a resolution (default 150 dpi). JPEG and TIFF are flattened onto white paper. A page too large for the renderer at that resolution is drawn at the most it allows, listed in `lower_dpi` ({page, dpi}). Includes unsaved edits.")
.cmd("export.image")
.with(schema(
json!({
@ -763,7 +873,12 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "folder"],
)),
t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.")
t("accessibility_autotag_preview", "Preview automatic tags", "Propose paragraph and heading tags for visible text blocks on one page. Returns page bounds, tentative roles and reasons. Does not change the PDF or claim accessibility compliance. Images, tables, lists, annotations and existing structure require separate review.")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("accessibility_autotag_apply", "Automatically tag text-only PDF", "Write marked content, a Document structure element, paragraph tags and a parent tree for every text object in an untagged, text-only PDF. Refuses graphics, images, annotations, forms, existing tags and malformed streams before changing anything. Review all resulting roles; this does not certify accessibility. Undoable.")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Findings on locatable figures, annotations and form widgets include bbox_user_space in PDF coordinates. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.")
.ro()
.cmd("a11y.check")
.with(schema(
@ -790,6 +905,34 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "path"],
)),
t("accessibility_review_record", "Accessibility review record", "Compare the same accessibility rules on two open versions of a PDF and write a JSON record to path. Includes document SHA-256 hashes, before/after rule statuses and finding counts, changed rules, and rules still needing review. Does not change either PDF or claim accessibility compliance. Open the original and edited copies as separate documents.")
.destructive()
.with(schema(
json!({
"before": doc(),
"after": doc(),
"path": { "type": "string" },
"all": { "type": "boolean", "description": "Include rules that are off by default (colour contrast)." },
}),
&["before", "after", "path"],
)),
t("accessibility_review_save", "Save accessibility review decisions", "Write a local JSON sidecar for an open PDF with rule-level human review decisions. The sidecar records the PDF SHA-256; replacing one tied to different bytes requires replace: true. Does not change the PDF or assert compliance.")
.destructive()
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "decisions": { "type": "array", "items": { "type": "object", "properties": { "rule": { "type": "string" }, "decision": { "type": "string", "enum": ["accepted", "needs_work", "skipped"] }, "page": { "type": "integer", "minimum": 1 }, "note": { "type": "string" } }, "required": ["rule", "decision"], "additionalProperties": false } }, "replace": { "type": "boolean" } }), &["doc", "path", "decisions"])),
t("accessibility_review_load", "Open accessibility review decisions", "Read a local review sidecar only when its recorded SHA-256 matches the open PDF. Returns the decisions without changing the PDF.")
.ro()
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("accessibility_review_html", "Accessibility review summary", "Compare two open versions of a PDF and write a self-contained HTML summary of the same evidence as accessibility_review_record: document hashes, outcome counts, rule changes, and rules still requiring review. Does not change either PDF or claim accessibility compliance.")
.destructive()
.with(schema(
json!({
"before": doc(),
"after": doc(),
"path": { "type": "string" },
"all": { "type": "boolean", "description": "Include rules that are off by default (colour contrast)." },
}),
&["before", "after", "path"],
)),
t("accessibility_fix", "Fix an accessibility problem", "Apply the checker's automatic fix for a rule: primary-language (value: the language, e.g. en-US), title (value: the title; default the current title or file name; also shows it in the title bar) or tab-order (every page tabs in structure order). Returns the rule's new status. Undoable.")
.with(schema(json!({ "doc": doc(), "rule": { "type": "string", "enum": ["primary-language", "title", "tab-order"] }, "value": { "type": "string" } }), &["doc", "rule"])),
t("accessibility_figures", "List figures", "The tagged figures (Figure elements, through the role map) in document order: figure number, page, alternate text and where the figure is drawn (top-left-origin points).")
@ -799,6 +942,25 @@ pub fn tools() -> Vec<ToolDef> {
t("accessibility_set_alt", "Set alternate text", "Set a figure's alternate text (alt; empty or omitted clears it), or mark it decorative (decorative: true: its content becomes an artifact and the figure leaves the tags). figure is a number from accessibility_figures. Returns the figures. Undoable.")
.cmd("a11y.alt_text")
.with(schema(json!({ "doc": doc(), "figure": { "type": "integer", "minimum": 1 }, "alt": { "type": "string" }, "decorative": { "type": "boolean" } }), &["doc", "figure"])),
t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text and language. Inline tag dictionaries have no stable object reference and are not listed.")
.ro()
.cmd("a11y.tags")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language. Empty or omitted value clears text properties; type requires a nonempty PDF name. Undoable.")
.with(schema(json!({
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language"] },
"value": { "type": "string" },
}), &["doc", "object", "generation", "property"])),
t("accessibility_move_tag", "Move structure tag", "Move a reachable indirect structure tag before or after a direct sibling in their parent's /K array, changing tagged reading order without changing page appearance. Use object and generation values from accessibility_tags. Rejects distinct parents, duplicate references and positions that are already current. Undoable.")
.with(schema(json!({
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
"anchor": { "type": "integer", "minimum": 1 },
"anchor_generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
"placement": { "type": "string", "enum": ["before", "after"] },
}), &["doc", "object", "generation", "anchor", "anchor_generation", "placement"])),
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
.cmd("form.merge_data")
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
@ -821,13 +983,16 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
&["doc", "field", "event"],
)),
t("doc_export_office", "Export to Word, HTML or RTF", "Export a PDF ▸ Word (.docx), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: paragraphs in reading order, headings from larger text, bold and italic, images where they fall, a page break between pages.")
t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited.")
.cmd("export.docx")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
.ro()
.cmd("standards.pdfa")
.with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"], "description": "Default 2b." } }), &["doc"])),
t("print_readiness", "Print readiness", "Verification only. Reports effective image resolution where each image is drawn (not against the page size), page boxes and bleed past the trim, output intents, fonts that are not embedded, and the PDF/A-2b structural result. Does not modify the document, embed fonts, invent a press profile, or check PDF/X.")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.")
.cmd("standards.pdfa")
.with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])),
@ -875,7 +1040,7 @@ pub fn tools() -> Vec<ToolDef> {
)),
t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.")
.with(schema(json!({ "enabled": { "type": "boolean" } }), &[])),
t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.")
t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.")
.cmd("ocr.recognize")
.with(schema(
json!({
@ -883,11 +1048,11 @@ pub fn tools() -> Vec<ToolDef> {
"pages": pages("to recognise (default: all)"),
"dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution pages are read at (default 300; a scanned page is read at most at its own resolution)." },
"language": { "type": "string", "enum": ["en"], "description": "Document language (default en)." },
"skip_text_pages": { "type": "boolean", "description": "Leave pages that already contain text alone (default true)." },
"skip_text_pages": { "type": "boolean", "description": "Leave pages that already contain text alone (default true). false also reads pictures on such pages, without adding their text again." },
}),
&["doc"],
)),
t("ocr_recognize_files", "Recognize text in multiple files", "Scan & OCR ▸ Recognize text ▸ In multiple files: read every page of each PDF in paths and write the searchable result into folder under the same name (pages that already have text are left alone). Returns, per file, the output path, word count and skipped pages, or the error.")
t("ocr_recognize_files", "Recognize text in multiple files", "Scan & OCR ▸ Recognize text ▸ In multiple files: read every page of each PDF in paths and write the searchable result into folder under the same name (pages that already have text are left alone unless skip_text_pages is false, as in ocr_recognize). Returns, per file, the output path, word count and skipped pages, or the error.")
.cmd("ocr.recognize_batch")
.with(schema(
json!({
@ -895,6 +1060,7 @@ pub fn tools() -> Vec<ToolDef> {
"folder": { "type": "string" },
"dpi": { "type": "number", "minimum": 72, "maximum": 600 },
"language": { "type": "string", "enum": ["en"] },
"skip_text_pages": { "type": "boolean", "description": "Leave pages that already contain text alone (default true); see ocr_recognize." },
}),
&["paths", "folder"],
)),
@ -919,7 +1085,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"fill_sign_add",
"Fill & Sign: type text or place a mark",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations.",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
)
.cmd("sign.fill.text")
.with(schema(
@ -929,7 +1095,10 @@ pub fn tools() -> Vec<ToolDef> {
"type": { "type": "string", "enum": ["text", "check", "cross", "dot", "line", "date", "signature", "initials"] },
"at": point(),
"text": { "type": "string", "minLength": 1 },
"font_size": { "type": "number", "minimum": 4, "maximum": 300, "description": "For type text or date (default 10 pt)." },
"path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." },
"scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." },
"sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." },
"format": { "type": "string", "description": "For type date: this pattern instead of the date-format preference, e.g. \"dd.mm.yyyy\"." },
"language": { "type": "string", "description": "For type date: month and weekday names in this language code (see fill_sign_date_format) instead of the preference." },
"author": { "type": "string" },
@ -941,7 +1110,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"doc_create",
"Create a PDF",
"Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly). Save it with doc_save and a path.",
"Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly; Western European (WinAnsi) characters only, others are refused). Save it with doc_save and a path.",
)
.with(schema(
json!({
@ -957,7 +1126,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["from"],
)),
t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. The open document is unchanged.")
t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.")
.cmd("optimize.reduce")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("doc_initial_view", "Initial view", "Read or change how the document opens (Document Properties ▸ Initial View) and its reading options: navigation (page, bookmarks, pages, attachments, layers), layout (default, single, continuous, two_up, two_up_continuous, two_up_cover, two_up_continuous_cover), magnification (default, actual, fit_page, fit_width, fit_height, fit_visible, or a percentage), page, window options (fit_window, center_window, full_screen, display_title), interface options (hide_menubar, hide_toolbar, hide_window_ui), language and binding (left, right). Only the given ones change; returns the result. Undoable.").with(schema(
@ -987,11 +1156,15 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("page_images", "List page images", "The raster images and grouped Form artwork a page draws (Edit a PDF): number, box (top-left-origin points), kind (image/form), pixel size ([0,0] for forms) and resource name. Forms include nested graphics and are edited as a whole. Numbers count forms and images together in drawing order, so a page with forms numbers its images differently than before forms were listed: always take numbers from a fresh page_images call.")
t("image_hit", "Hit test page artwork", "List image/Form numbers whose placement and all clipping paths contain point [x,y] in top-left-origin points. Bezier curves, even-odd/nonzero fill and holes are respected. Read only.")
.ro()
.cmd("edit.edit_text")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}}), &["doc","page","point"])),
t("page_images", "List page images", "The raster images and grouped Form artwork a page draws (Edit a PDF): number, full placement rect and visible_rect after page/path clipping (top-left-origin points; null if fully hidden), kind (image/form), pixel size ([0,0] for forms) and resource name. Forms include nested graphics and are edited as a whole. Numbers count forms and images together in drawing order, so a page with forms numbers its images differently than before forms were listed: always take numbers from a fresh page_images call.")
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new box in top-left-origin points), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
.cmd("edit.edit_text")
.with(schema(
json!({
@ -1009,15 +1182,30 @@ pub fn tools() -> Vec<ToolDef> {
.destructive()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, "path": { "type": "string" } }), &["doc", "page", "image", "path"])),
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (lines grouped by font, size, alignment and spacing): number, text, its line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.")
t("text_font_files", "List installed document fonts", "Discover openly licensed, embeddable TrueType/OpenType files on request. Optional directories (at most 16) restrict the scan; with --root, only that root is scanned by default and supplied folders must stay inside it. The scan skips symlinks, collections and invalid/licence-restricted fonts, with depth, entry and byte budgets. Select a returned path with text_font_import.")
.ro().cmd("edit.edit_text")
.with(schema(json!({"directories":{"type":"array","items":{"type":"string"},"maxItems":16}}), &[])),
t("text_font_import", "Load document font", "Load an explicitly selected, openly licensed full TrueType/OpenType face into a page. The program's family, embedding permissions and licence declaration are validated. Completes source subsets of the same face without replacing existing glyphs. Returns a font resource usable with text_edit.font_resource. Undoable; respects root confinement. No system-interface fallback is exported.")
.cmd("edit.edit_text")
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}, "path":{"type":"string"}}), &["doc","page","path"])),
t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources can be selected by text_edit.font_resource; preview_only faces are not PDF resource names.")
.ro().cmd("edit.edit_text")
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.")
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.")
t("text_edit_layout", "Measure text draft", "Measure a paragraph draft using the same PDF rewrite as Apply, without changing the document. Returns source_rect, draft_rect (top-left-origin points), overflow, and the embedded sfnt source faces available to the inline editor. Text defaults to the unchanged paragraph. Optional size, width, font_resource and disjoint UTF-8 ranges preview formatting changes.")
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])),
t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.")
.cmd("edit.edit_text")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 },
"paragraph": { "type": "integer", "minimum": 1 },
"font_resource": { "type": "string", "description": "Explicit source/full page font resource from text_fonts or text_font_import." },
"ranges": { "type": "array", "maxItems": 1024, "items": { "type": "object", "properties": {"range":{"type":"array","items":{"type":"integer","minimum":0},"minItems":2,"maxItems":2},"font":{"type":"string","enum":["helvetica","times","courier"]},"bold":{"type":"boolean"},"italic":{"type":"boolean"},"font_resource":{"type":"string"},"size":{"type":"number","exclusiveMinimum":0},"color":{"type":"string"},"underline":{"type":"boolean"},"char_spacing":{"type":"number"},"scale":{"type":"number"}},"required":["range"]}, "description":"Disjoint UTF-8 byte ranges in replacement text; unspecified source formatting survives." },
"text": { "type": "string" },
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] },
"bold": { "type": "boolean" },
@ -1038,7 +1226,7 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
.cmd("optimize.advanced")
.with(schema(
json!({

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,230 @@
use pdfcraft_automation::{Automation, Content};
use pdfcraft_engine::Session;
use serde_json::{Value, json};
fn result(auto: &mut Automation, args: Value) -> Value {
match auto.call("doc_batch", &args).unwrap().remove(0) {
Content::Json(value) => value,
_ => panic!("expected a report"),
}
}
// Fresh fixtures include the current timestamp; retain their bytes for output comparisons.
fn pdf() -> Vec<u8> {
Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()
}
#[test]
fn batch_recurses_preserves_paths_continues_errors_and_never_replaces_outputs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
std::fs::create_dir_all(dir.path().join("input/output/sub")).unwrap();
std::fs::write(dir.path().join("input/a.txt"), "Hello").unwrap();
std::fs::write(dir.path().join("input/b.pdf"), b"broken").unwrap();
std::fs::write(dir.path().join("input/sub/a.txt"), "World").unwrap();
std::fs::write(dir.path().join("input/notes.bin"), b"ignore").unwrap();
std::fs::write(dir.path().join("input/output/sub/a.pdf"), b"precious").unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let args = json!({"paths":["input","input/a.txt"],"steps":["convert","optimize"],"out_dir":"input/output","recursive":true});
let report = result(&mut auto, args.clone());
assert_eq!(report["completed"], 2, "{report}");
assert_eq!(report["failed"], 1);
assert_eq!(report["skipped"], 1);
assert_eq!(std::fs::read(dir.path().join("input/output/sub/a.pdf")).unwrap(), b"precious");
assert_eq!(std::fs::read_to_string(dir.path().join("input/a.txt")).unwrap(), "Hello");
for name in ["a.pdf", "sub/a (2).pdf"] {
let bytes = std::fs::read(dir.path().join("input/output").join(name)).unwrap();
let mut session = Session::new();
let id = session.open(name, None, std::sync::Arc::new(bytes), None).unwrap();
assert_eq!(session.get(id).unwrap().info.pages.len(), 1);
}
assert!(!dir.path().join("input/output/b.pdf").exists());
assert!(auto.session().docs().is_empty());
let again = result(&mut auto, args);
assert_eq!(again["completed"], 2, "previous outputs must not become new inputs: {again}");
assert!(dir.path().join("input/output/a (2).pdf").exists());
}
#[test]
fn batch_is_nonrecursive_by_default_and_stop_reports_unprocessed_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
std::fs::write(dir.path().join("input/0.pdf"), b"broken").unwrap();
let original = pdf();
std::fs::write(dir.path().join("input/1.pdf"), &original).unwrap();
std::fs::write(dir.path().join("input/sub/2.pdf"), &original).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","stop_on_error":true}));
assert_eq!(report["failed"], 1);
assert_eq!(report["completed"], 0);
assert_eq!(report["skipped"], 1);
assert_eq!(report["stopped"], true);
assert!(!dir.path().join("out").exists());
let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out"}));
assert_eq!(report["completed"], 1);
assert!(!dir.path().join("out/sub").exists());
assert!(std::fs::metadata(dir.path().join("out/1.pdf")).unwrap().len() <= original.len() as u64);
}
#[test]
fn invalid_batch_arguments_and_outside_paths_write_nothing() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("input")).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
for change in [
json!({"steps":["convert","convert"]}),
json!({"steps":["optimize","convert"]}),
json!({"steps":[]}),
json!({"steps":["nope"]}),
json!({"recursive":"yes"}),
json!({"paths":[]}),
json!({"paths":["input","../outside"]}),
json!({"out_dir":"../outside"}),
json!({"out_dir":"input"}),
json!({"out_dir":"."}),
json!({"quality":0}),
json!({"quality":"80"}),
json!({"ppi":2000}),
json!({"steps":["ocr"],"language":"not-a-language"}),
json!({"steps":["ocr"],"dpi":1}),
] {
let mut args = json!({"paths":["input"],"steps":["optimize"],"out_dir":"out"});
args.as_object_mut().unwrap().extend(change.as_object().unwrap().clone());
assert!(auto.call("doc_batch", &args).is_err(), "{args}");
assert!(!dir.path().join("out").exists(), "invalid arguments must be refused before writing");
}
}
#[test]
fn explicitly_selected_subfolder_is_processed_without_recursive() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
std::fs::write(dir.path().join("input/sub/page.pdf"), pdf()).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input","input/sub"],"steps":["convert"],"out_dir":"out"}));
assert_eq!(report["completed"], 1, "{report}");
assert!(dir.path().join("out/page.pdf").exists());
}
#[test]
fn explicit_file_can_write_a_numbered_copy_beside_its_source() {
let dir = tempfile::tempdir().unwrap();
let original = pdf();
std::fs::write(dir.path().join("page.pdf"), &original).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["page.pdf"],"steps":["convert"],"out_dir":"."}));
assert_eq!(report["completed"], 1, "{report}");
assert_eq!(std::fs::read(dir.path().join("page.pdf")).unwrap(), original);
assert_eq!(std::fs::read(dir.path().join("page (2).pdf")).unwrap(), original);
}
#[cfg(unix)]
#[test]
fn non_utf8_discovered_names_are_reported_without_panicking() {
use std::os::unix::ffi::OsStrExt;
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("input")).unwrap();
let invalid = dir.path().join("input").join(std::ffi::OsStr::from_bytes(b"bad-\xff.pdf"));
if let Err(error) = std::fs::write(&invalid, pdf()) {
// APFS rejects this filename before batch discovery can see it. Keep the
// discovery assertions on filesystems that allow it; no other I/O error
// is an unsupported fixture.
#[cfg(target_os = "macos")]
if error.raw_os_error() == Some(92) {
assert!(!invalid.exists());
assert_eq!(std::fs::read_dir(dir.path().join("input")).unwrap().count(), 0);
assert!(!dir.path().join("out").exists());
let valid = dir.path().join("input/valid-λ.pdf");
let original = pdf();
std::fs::write(&valid, &original).unwrap();
assert_eq!(std::fs::read(&valid).unwrap(), original);
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out"}));
assert_eq!(report["completed"], 1, "{report}");
assert_eq!(report["failed"], 0, "{report}");
assert_eq!(std::fs::read(dir.path().join("out/valid-λ.pdf")).unwrap(), original);
eprintln!(
"non-UTF-8 fixture capability unavailable: macOS filesystem rejected creation with EILSEQ92; valid-Unicode batch companion passed; invalid-name discovery still requires Linux/FreeBSD or a supporting filesystem"
);
return;
}
panic!("could not create the non-UTF-8 fixture: {error}");
}
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out"}));
assert_eq!(report["failed"], 1);
assert!(report["files"][0]["error"].as_str().unwrap().contains("UTF-8"));
}
#[cfg(unix)]
#[test]
fn directory_links_are_not_followed_and_output_links_cannot_escape() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
std::fs::create_dir(dir.path().join("outside")).unwrap();
std::fs::create_dir(dir.path().join("out")).unwrap();
std::fs::write(dir.path().join("input/sub/page.pdf"), pdf()).unwrap();
std::os::unix::fs::symlink(dir.path().join("input"), dir.path().join("input/loop")).unwrap();
std::os::unix::fs::symlink(dir.path().join("outside"), dir.path().join("out/sub")).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out","recursive":true}));
assert_eq!(report["skipped"], 1);
assert_eq!(report["failed"], 1);
assert_eq!(std::fs::read_dir(dir.path().join("outside")).unwrap().count(), 0);
}
#[test]
fn batch_ocr_uses_existing_models_or_reports_the_missing_models_per_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["page.pdf"],"steps":["ocr","optimize"],"out_dir":"out"}));
if pdfcraft_engine::ocr::available() {
assert_eq!(report["completed"], 1, "{report}");
assert_eq!(report["files"][0]["steps"][0]["skipped_pages"], 1);
assert!(dir.path().join("out/page.pdf").exists());
} else {
assert_eq!(report["failed"], 1, "{report}");
assert!(report["files"][0]["error"].as_str().unwrap().contains("model"), "{report}");
assert!(!dir.path().join("out/page.pdf").exists());
}
}
#[test]
fn batch_output_collisions_are_safe_under_concurrent_writers() {
let dir = tempfile::tempdir().unwrap();
let original = pdf();
std::fs::write(dir.path().join("page.pdf"), &original).unwrap();
let barrier = std::sync::Barrier::new(2);
std::thread::scope(|scope| {
let handles: Vec<_> = (0..2)
.map(|_| {
scope.spawn(|| {
let mut auto = Automation::new().with_root(dir.path()).unwrap();
barrier.wait();
result(&mut auto, json!({"paths":["page.pdf"],"steps":["convert"],"out_dir":"out"}))
})
})
.collect();
let reports: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert!(reports.iter().all(|r| r["completed"] == 1), "{reports:?}");
assert_ne!(reports[0]["files"][0]["output"], reports[1]["files"][0]["output"]);
});
for name in ["page.pdf", "page (2).pdf"] {
assert_eq!(std::fs::read(dir.path().join("out").join(name)).unwrap(), original);
}
assert_eq!(std::fs::read_dir(dir.path().join("out")).unwrap().count(), 2, "no staging files remain");
}
#[cfg(feature = "mcp")]
#[test]
fn mcp_batch_errors_keep_the_structured_per_file_report() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("broken.pdf"), b"broken").unwrap();
let input = json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"doc_batch","arguments":{"paths":["broken.pdf"],"steps":["optimize"],"out_dir":"out"}}}).to_string() + "\n";
let mut out = Vec::new();
pdfcraft_automation::mcp::McpServer::new(Automation::new().with_root(dir.path()).unwrap()).serve(input.as_bytes(), &mut out).unwrap();
let report: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(report["result"]["isError"], true, "{report}");
assert_eq!(report["result"]["structuredContent"]["failed"], 1);
}

View File

@ -0,0 +1,153 @@
//! End-to-end bulk Field Properties through the same tools used by CLI and MCP.
use pdfcraft_automation::{Automation, Content, ToolError};
use pdfcraft_engine::{DocId, Edit, FieldFont, FieldProps, FieldValue, NewField, Session, field_flags};
use serde_json::{Value, json};
fn call(a: &mut Automation, args: Value) -> Value {
match a.call("form_set_props", &args).unwrap().remove(0) {
Content::Json(v) => v,
other => panic!("expected JSON: {other:?}"),
}
}
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
match a.call(name, &args).unwrap().remove(0) {
Content::Json(v) => v,
other => panic!("expected JSON: {other:?}"),
}
}
fn setup(test: &str) -> (Automation, u64, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("pdfcraft-form-bulk-{test}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::new();
let bytes = s.create_blank(300.0, 400.0, 2).unwrap();
let id = s.open("form.pdf", None, bytes, None).unwrap();
for (name, page, x) in [("alpha", 0, 30.0), ("beta", 1, 40.0)] {
s.apply(id, Edit::AddField { page, rect: [x, 300.0, x + 180.0, 324.0], kind: NewField::Text { multiline: false }, name: Some(name.into()) })
.unwrap();
s.apply(id, Edit::SetFieldValue { name: name.into(), value: FieldValue::Text(name.into()) }).unwrap();
let mut look = s.get(id).unwrap().field_look(name).unwrap();
look.fill = Some(if name == "alpha" { [1.0, 0.9, 0.8] } else { [0.8, 0.9, 1.0] });
look.font = if name == "alpha" { FieldFont::Courier } else { FieldFont::Times };
s.apply(
id,
Edit::SetFieldProps {
name: name.into(),
props: Box::new(FieldProps { look: Some(look), tooltip: Some(name.into()), ..Default::default() }),
},
)
.unwrap();
}
std::fs::write(dir.join("form.pdf"), s.save_bytes(id).unwrap().as_slice()).unwrap();
// Saves stamp /ModDate from the clock: pin it, or two saves a second apart differ.
let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000);
let id = tool(&mut a, "doc_open", json!({"path": "form.pdf"}))["doc"].as_u64().unwrap();
(a, id, dir)
}
fn bytes(a: &Automation, id: u64) -> Vec<u8> {
a.session().save_bytes(DocId(id)).unwrap().as_ref().clone()
}
#[test]
fn bulk_properties_are_one_undo_step_preserve_other_looks_and_survive_save() {
let (mut a, id, dir) = setup("roundtrip");
let before = bytes(&a, id);
let original =
[a.session().get(DocId(id)).unwrap().field_look("alpha").unwrap(), a.session().get(DocId(id)).unwrap().field_look("beta").unwrap()];
let out = call(&mut a, json!({"doc": id, "fields": ["alpha", "beta"], "required": true, "appearance": {"width": 4, "border": "#FF0000"}}));
assert_eq!(out["fields"], json!(["alpha", "beta"]));
let doc = a.session().get(DocId(id)).unwrap();
assert_eq!(doc.can_undo(), Some("Change field properties"));
for (name, look) in [("alpha", original[0]), ("beta", original[1])] {
let changed = doc.field_look(name).unwrap();
assert_eq!(changed.width, 4.0);
assert_eq!(changed.border, Some([1.0, 0.0, 0.0]));
assert_eq!((changed.fill, changed.font, changed.text), (look.fill, look.font, look.text));
let f = doc.form.iter().find(|f| f.name == name).unwrap();
assert!(f.has(field_flags::REQUIRED));
assert_eq!(f.value, [name]);
assert_eq!(f.tooltip.as_deref(), Some(name));
}
tool(&mut a, "edit_undo", json!({"doc": id}));
assert_eq!(bytes(&a, id), before);
tool(&mut a, "edit_redo", json!({"doc": id}));
tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"}));
let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap();
let doc = a.session().get(DocId(reopened)).unwrap();
assert_eq!(doc.field_look("beta").unwrap().font, original[1].font);
assert_eq!(doc.field_look("beta").unwrap().width, 4.0);
assert_eq!(doc.form.iter().find(|f| f.name == "beta").unwrap().value, ["beta"]);
let image = a.call("page_render", &json!({"doc": reopened, "page": 2, "dpi": 72})).unwrap();
assert!(image.iter().any(|c| matches!(c, Content::Png { width: 300, height: 400, .. })));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn bulk_property_arguments_are_strict_and_fail_without_any_edit() {
let (mut a, id, dir) = setup("invalid");
let before = bytes(&a, id);
let cases = [
json!({"doc": id, "required": true}),
json!({"doc": id, "field": "alpha", "fields": ["beta"], "required": true}),
json!({"doc": id, "fields": [], "required": true}),
json!({"doc": id, "fields": ["alpha", "alpha"], "required": true}),
json!({"doc": id, "fields": ["alpha", 7], "required": true}),
json!({"doc": id, "fields": ["alpha", "beta"], "name": "same"}),
json!({"doc": id, "fields": ["alpha", "beta"], "rotation": 90}),
json!({"doc": id, "fields": ["alpha", "beta"], "rect": [0, 0, 20, 20]}),
json!({"doc": id, "fields": ["alpha", "beta"]}),
json!({"doc": id, "fields": ["alpha", "beta"], "required": "yes"}),
json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"style": "invalid"}}),
json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"width": -1}}),
json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"width": "thick"}}),
json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"style": 3}}),
json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"font": true}}),
json!({"doc": id, "fields": ["alpha", "beta"], "appearance": {"unknown": true}}),
json!({"doc": id, "fields": ["alpha", "beta"], "requred": true}),
json!({"doc": id, "fields": vec!["alpha"; 1001], "required": true}),
];
for args in cases {
assert!(matches!(a.call("form_set_props", &args), Err(ToolError::InvalidArgs(_))), "{args}");
assert_eq!(bytes(&a, id), before, "{args}");
}
assert!(matches!(a.call("form_set_props", &json!({"doc": id, "fields": ["alpha", "missing"], "required": true})), Err(ToolError::Failed(_))));
assert_eq!(bytes(&a, id), before);
assert!(a.session().get(DocId(id)).unwrap().can_undo().is_none());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_locked_last_field_rolls_back_earlier_changes_and_unlocks_with_the_batch() {
let (mut a, id, dir) = setup("locked");
call(&mut a, json!({"doc": id, "field": "beta", "locked": true}));
let before = bytes(&a, id);
assert!(matches!(
a.call("form_set_props", &json!({"doc": id, "fields": ["alpha", "beta"], "tooltip": "Shared help"})),
Err(ToolError::Failed(_))
));
assert_eq!(bytes(&a, id), before);
assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties"));
call(&mut a, json!({"doc": id, "fields": ["alpha", "beta"], "locked": false, "tooltip": "Shared help"}));
assert!(a.session().get(DocId(id)).unwrap().form.iter().all(|f| !f.locked() && f.tooltip.as_deref() == Some("Shared help")));
tool(&mut a, "edit_undo", json!({"doc": id}));
assert_eq!(bytes(&a, id), before);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically() {
let (mut a, id, dir) = setup("options");
let before = bytes(&a, id);
assert!(matches!(a.call("form_set_props", &json!({"doc": id, "fields": ["alpha", "beta"], "flags": {"comb": true}})), Err(ToolError::Failed(_))));
assert_eq!(bytes(&a, id), before);
call(&mut a, json!({"doc": id, "fields": ["alpha", "beta"], "max_length": 20, "align": "center", "flags": {"comb": true, "spell_check": false}}));
let doc = a.session().get(DocId(id)).unwrap();
assert!(doc.form.iter().all(|f| f.max_len == Some(20) && f.quadding == 1 && f.has(field_flags::COMB) && f.has(field_flags::DO_NOT_SPELL_CHECK)));
assert_eq!(doc.form.iter().map(|f| f.value[0].as_str()).collect::<Vec<_>>(), ["alpha", "beta"]);
tool(&mut a, "edit_undo", json!({"doc": id}));
assert_eq!(bytes(&a, id), before);
std::fs::remove_dir_all(dir).unwrap();
}

View File

@ -81,8 +81,9 @@ fn zoo() -> Vec<Value> {
/// Paths that must never resolve inside the root. Absolute ones point into the test's own temp
/// `base` (the root's parent), never at real system files or network shares, so a confinement
/// bug can only touch what the test made.
fn escapes(base: &std::path::Path) -> Vec<String> {
/// bug can only touch what the test made. `linked`: the root holds `link-out`, a symbolic link
/// to `base`.
fn escapes(base: &std::path::Path, linked: bool) -> Vec<String> {
let mut out: Vec<String> = vec![
"../outside.pdf".into(),
"../../outside.pdf".into(),
@ -92,8 +93,10 @@ fn escapes(base: &std::path::Path) -> Vec<String> {
base.join("outside.pdf").to_string_lossy().into_owned(),
base.join("new-outside.pdf").to_string_lossy().into_owned(),
base.join("sub").join("new-outside.pdf").to_string_lossy().into_owned(),
"link-out/outside.pdf".into(), // through a planted symbolic link, when the system allows one
];
if linked {
out.push("link-out/outside.pdf".into());
}
// Backslashes separate only on Windows; elsewhere this is an ordinary file name in the root.
if cfg!(windows) {
out.push(r"..\..\outside.pdf".into());
@ -101,6 +104,23 @@ fn escapes(base: &std::path::Path) -> Vec<String> {
out
}
/// A symbolic link to a folder, where the system allows one (Windows needs Developer Mode or an
/// administrator for it, #948).
fn dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_dir(target, link)
}
#[cfg(not(any(unix, windows)))]
{
Err(std::io::Error::other(format!("no symbolic links here: {} {}", target.display(), link.display())))
}
}
/// The properties of `def` whose values are paths, by name and whether they are a list.
fn path_props(def: &pdfcraft_automation::ToolDef) -> Vec<(String, bool)> {
let Some(props) = def.input_schema["properties"].as_object() else { return Vec::new() };
@ -223,11 +243,15 @@ fn no_tool_escapes_the_root() {
.expect("outside file");
let before = listing(&base);
// A link inside the root pointing out of it, where the system allows one to be made.
#[cfg(unix)]
let _ = std::os::unix::fs::symlink(&base, root.join("link-out"));
#[cfg(windows)]
let _ = std::os::windows::fs::symlink_dir(&base, root.join("link-out"));
// A link inside the root pointing out of it, where the system allows one to be made. Without
// it `link-out/…` is an ordinary path inside the root, which tools that write rightly accept.
let linked = match dir_symlink(&base, &root.join("link-out")) {
Ok(()) => true,
Err(e) => {
eprintln!("escapes through a symbolic link not checked: {e}");
false
}
};
let mut a = Automation::new().with_root(&root).expect("root");
@ -247,7 +271,7 @@ fn no_tool_escapes_the_root() {
for def in tools() {
for (prop, is_list) in path_props(&def) {
for escape in escapes(&base) {
for escape in escapes(&base, linked) {
let mut obj = plausible_with(&def, Some(doc));
obj.insert(prop.clone(), if is_list { json!([escape]) } else { json!(escape) });
let args = Value::Object(obj);

Some files were not shown because too many files have changed in this diff Show More