Compare commits

...

70 Commits
v0.7.1 ... main

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
190 changed files with 13150 additions and 797 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

@ -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,7 +4,7 @@
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 (269)
## In this repository (270)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@ -222,6 +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) |
| `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. |

View File

@ -7,7 +7,7 @@ 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 = "e04ab5246125efae2bad439e62c150fc10649c4b6796b30565615f5bd7509ec6"
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`).
@ -30,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 = "7f600eaa47f23fac4f9f0fece1454e59756c8dc57c7c697502cb02ee21410ac0"
sha256 = "d8ea23644d81476e3fd35c09780ab9b3ac8c9e28bed4e14e1cbc6493bb896d91"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@ -41,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 = "37733adc79d7d32a057811b4dc5eb31251ef8ec91f1dac430253c69faac1c33f"
sha256 = "26dd0b0d697fd40db996fc102f3c7f2320bdeb6d6af38dbdde353bd5d940baad"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@ -52,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 = "285b689b5224a66079738ab39d5a27e5f9ba72e1ab42e148f263b7ea320c4729"
sha256 = "322b8cda21a2b8d157716182846619bd92bfb31263d1f8d13da7eaad365fbfd7"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@ -63,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 = "e8154a154129ca158396de84d2d432ce1c587477795a6a14985f7834db255744"
sha256 = "21bf9c73b482eb987ddaf839b4a4aa8f9938d5aa10428e6d39beea254a95a0c0"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@ -74,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 = "50d4e66209e8b56707bcd148d4644eb0ad9f49dfc195287cfbd851ffbe5c3830"
sha256 = "fef35ae1771edf2e044e72aebb958bb4827ded57234207f96b6cb7a250fedb22"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@ -85,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 = "13e677d44d5224e60af28fcc89d8f8af552368ca3c11f0a871498c60fefa7ee2"
sha256 = "d723622298b10400e9a1ba697cbebd831f5e0f7e5bbe4545b97f61e64db7535f"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@ -96,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 = "e81112d69acc2e1bbb44c5252f8f323b1d637059b5ac5f4c9fcd3eb985a53442"
sha256 = "686ab9658b7ef9dd7f3af05ce73f99f1ab2a75f8357bad6f7e987d40a8730df9"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@ -107,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 = "df3d3650af984251372bf22b859ba6aca47f26eeff790e834d57a5a4071a1b48"
sha256 = "90b94038699aa74aa48ffbda3e90a2255b65d0dbf081e8ecf20c5804940fc129"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@ -118,7 +118,7 @@ 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 = "c9e453382d9d5c83f7b0f986befc6113a62b4736a6b94a6462805cead406753b"
sha256 = "5aa6475df91a3e82010aee198c2e29bb80328505bbd0240f3828e36a5d6dfdf3"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@ -129,7 +129,7 @@ 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 = "2a205868b13f719dbc91fdc50f1133f5f317aa2245143bfeaa048190bc9c7d8c"
sha256 = "e6d306d9bf289fac2fe8fcfa6f9d0a51b650728e45669289d391be2031076692"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@ -140,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 = "3053aff461c289404bf3009c611037870b945692c65ce8d7c14ad26ac477660c"
sha256 = "277f4995a87297a2fcc473df1feebdbdf3378fa460a7f85eb1a0a151cee40a2b"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@ -151,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 = "882b3937802bbbd5afe0087264ee4118161351e8828cb02b608488ccfe39bfe9"
sha256 = "01b32370cedce935b74925858a79ee691dc2a5560ba5eb758de2de1c243c528e"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@ -162,7 +162,7 @@ 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 = "8cca054e167995ca6d95bc45fc0f75385a15a78852e4b173bd4c5b7b651c5a9d"
sha256 = "f8ae97901c61dfdc0a844a23356173211bb052b3ff54128d7edc8097c535eee9"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
@ -173,7 +173,7 @@ 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 = "2f4a6429c0c290a53268733dac08da221085992036a4d304d7663d7947d806d3"
sha256 = "7107ba77577794395451dd85355a86ef3e033a7f240b72414b877b9b43adb19e"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@ -184,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 = "2792f38b1699269b5886fa99a17c7773c3934df95dc4e2db3ba6a475dd652ba0"
sha256 = "114b3d50531eb1682535c7a646e62914b0c9afaa5d5d1b9f66b94fd472e4e7b3"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@ -195,7 +195,7 @@ 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 = "937ac7eac4230c9a1d26d6bd7ef4f08f8d8c9d391a091682e8758fba3ca9678e"
sha256 = "f56ec5f4cb7d1992041df0783f1cfd76c27d5d180c3f73c60b104e3fd02f600d"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@ -206,7 +206,7 @@ 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 = "7ced08c3485c5eddf92767e6deb009d72351d70cefe60be654cfdac441f40f4b"
sha256 = "113fdf57e28fcff90d212fb468011bedebfb98ac100218d29b584f4de4c87912"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
@ -217,7 +217,7 @@ 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 = "4e70c6db374db37bf70a17e251675b94128fb11b4d6d20ef669b36fd4ae14ff1"
sha256 = "2b5edfe026578ed475f07f94f1d1ed4a3dac93a745c19209589936e2d67f6ec5"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
@ -228,7 +228,7 @@ 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 = "d2e6be7cf74525310a203bed7434e7f69d2288d337ee696de2882df8da31a746"
sha256 = "fe58835b0538f470b463f078e1b4cdf78a8e2632056bc61c6946071478f39a64"
[[asset]]
path = "assets/icons/accessibility.svg"
@ -2364,6 +2364,18 @@ kind = "data"
usage = "Default CMYK colour conversion (vendored hayro-interpret)"
sha256 = "73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0"
[[asset]]
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 = "LICENSE-MIT"
kind = "data"
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"
version = "0.36.2"
@ -3339,4 +3351,4 @@ 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 = "6c01a439220415b83acab79c014286e5871ba7946a9c3b9a4776be797d55a086"
sha256 = "9bbbd0d516f284b571ad6e2ed811f6bbdee283cea8483686f4aa939db38ea288"

View File

@ -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.

1135
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ repository = "https://github.com/storytold/pdfcraft"
# 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 = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f03ec27ab2c405969428512178f746183cd55ca0" }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.7.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.7.0" }

View File

@ -2,6 +2,14 @@
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
> **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)
@ -141,9 +149,23 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **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.
- **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.
@ -176,6 +198,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
Newest first: the date, what moved, and the new overall estimate.
- **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.
- **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.
@ -403,6 +427,8 @@ Newest first: the date, what moved, and the new overall estimate.
| 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. |

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,11 +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\": {…}}, …]
@ -89,6 +95,7 @@ fn main() -> ExitCode {
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..]),
@ -141,7 +148,7 @@ 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))?;
@ -331,11 +338,20 @@ 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(),
@ -439,6 +455,17 @@ fn edit(args: &[String]) -> Result<(), CliError> {
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())?;
@ -610,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()));
}

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

@ -224,3 +224,18 @@ fn the_append_destination_and_counts_grown_by_earlier_edits_are_accepted() {
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

@ -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

@ -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"] }

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

@ -32,6 +32,7 @@ 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;
@ -126,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();
@ -155,7 +185,7 @@ fn main() -> eframe::Result {
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(),
@ -168,14 +198,17 @@ 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}"),
}
@ -190,7 +223,7 @@ fn main() -> eframe::Result {
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")]
@ -200,7 +233,7 @@ 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),
@ -218,7 +251,7 @@ 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,
@ -258,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])
@ -274,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() };
@ -291,6 +328,8 @@ 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>(
@ -299,7 +338,7 @@ fn app_creator<'a>(
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();
@ -313,6 +352,7 @@ fn app_creator<'a>(
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.
@ -484,6 +524,11 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
/// 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,
@ -727,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");
@ -752,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]
@ -781,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:?}");
@ -900,7 +974,7 @@ mod tests {
// than the device allows.
use eframe::wgpu;
let _gpu = gpu();
let native = super::native_options(false, eframe::Renderer::Wgpu);
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")
};
@ -967,7 +1041,7 @@ mod tests {
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, eframe::Renderer::Wgpu);
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() };

View File

@ -27,7 +27,7 @@ fn parse(output: &str) -> f32 {
/// 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"))]
fn gsettings(args: &[&str]) -> Option<String> {
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()?;

View File

@ -126,3 +126,6 @@ sync_github_name = true
real_name = "Jeremy Yao"
display_name = "Jeremy Yao"
sync_github_name = false
[people.webdevtodayjason]
display_name = "Jason Brashear"

View File

@ -14,5 +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

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

@ -13,6 +13,7 @@ use std::collections::{BTreeSet, HashMap};
use pdfcraft_cos::Document;
mod alt;
mod autotag;
mod content;
mod headings;
mod report;
@ -20,6 +21,7 @@ mod structure;
mod tags;
pub use alt::{AltError, Figure, figures, mark_decorative, set_alt};
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};

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(

View File

@ -47,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

@ -264,6 +264,11 @@ 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
@ -632,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

@ -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 },
@ -1090,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));
@ -1236,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],
@ -1247,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")
@ -1263,6 +1270,21 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio
Ok(())
}
/// 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).
///
@ -1271,19 +1293,19 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio
/// 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> {
let d = annot_dict(doc, r);
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)
)));
}
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
@ -1300,8 +1322,8 @@ fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
/// 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)?;
// Image stamp restyling must retain the placement's page-axis correction.
if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
// 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")?);
@ -1782,13 +1804,13 @@ fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Res
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);
})?;
set_appearance(doc, r)?;
Ok(())
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
@ -1800,6 +1822,9 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
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 {
@ -1825,7 +1850,16 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
}
touch(d, meta);
})?;
if new_text { draw_appearance(doc, r) } else { 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 {
@ -1862,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 {
@ -1900,7 +1936,7 @@ 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,
@ -1926,6 +1962,8 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
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)),
@ -1935,7 +1973,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
}
touch(d, meta);
})?;
set_appearance(doc, r)
redraw_or_restore(doc, r, before)
}
/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent.

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");
}
@ -1219,9 +1220,13 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() {
on_page.push(Object::Ref(r));
set_annots(&mut doc, page, on_page).unwrap();
let k = list(&doc, 0).len() - 1;
let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap_err();
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].get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")), Some(drawn), "its appearance is kept");
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('欢'));

View File

@ -18,8 +18,9 @@ shading and highlights don't either: they're painted before the text, or light a
Limits, by design and documented rather than hidden:
- only `re` rectangles closed by a fill count as covers (not arbitrary paths, shadings or
patterns), and a translucent one (`/ca` below 1) is not flagged;
- 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.

View File

@ -1,10 +1,11 @@
//! 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. 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 (arbitrary filled paths, shadings, patterns) is
//! 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
@ -45,6 +46,58 @@ struct Gs {
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 }
@ -79,8 +132,7 @@ impl<'a> Walker<'a> {
depth: usize,
at_do: Option<(usize, usize)>,
) -> Result<(), AuditError> {
// A pending `re` rectangle, in the user space active when it was built.
let mut pending: Option<[f64; 4]> = None;
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,
@ -95,7 +147,7 @@ impl<'a> Walker<'a> {
if let Some(g) = stack.pop() {
*gs = g;
}
pending = None;
path = Path::Empty;
}
b"cm" => {
let before = gs.ctm;
@ -105,9 +157,9 @@ impl<'a> Walker<'a> {
gs.ctm = m.then(&gs.ctm);
}
// A `cm` that leaves the CTM unchanged (an identity matrix) cannot move the
// pending rectangle, so keep it for the fill (#1333).
if gs.ctm != before {
pending = None;
// pending path, so keep it for the fill (#1333).
if gs.ctm != before && !matches!(path, Path::Empty) {
path = Path::Other;
}
}
b"g" => {
@ -138,7 +190,7 @@ impl<'a> Walker<'a> {
}
}
b"re" => {
pending = op.nums::<4>().and_then(|[x, y, w, h]| {
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)
@ -146,18 +198,39 @@ impl<'a> Walker<'a> {
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) = pending.take()
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 });
}
// Any other path construction clears the pending rectangle.
}
b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"S" | b"s" | b"n" | b"W" | b"W*" => {
pending = None;
}
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 {
@ -165,7 +238,7 @@ impl<'a> Walker<'a> {
}
self.walk_xobject(name, gs, resources, depth, pos)?;
}
pending = None;
path = Path::Empty;
}
_ => {}
}
@ -206,6 +279,13 @@ impl<'a> Walker<'a> {
}
}
/// 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

View File

@ -227,3 +227,48 @@ fn self_drawing_form_fails_the_audit() {
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

@ -16,6 +16,7 @@ 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 }

View File

@ -25,9 +25,38 @@ 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):
@ -134,3 +163,24 @@ faces complete absent subset glyphs. `text_edit.ranges` accepts disjoint UTF-8 b
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

@ -203,6 +203,16 @@ fn review_html(record: &Value) -> Result<String> {
}
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)?;
@ -490,7 +500,7 @@ impl Automation {
}
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;

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

@ -12,6 +12,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod a11y;
mod batch;
mod comments;
mod content;
mod conventions;
@ -24,6 +25,7 @@ mod printing;
mod reading;
mod redact;
mod signing;
mod text_cache;
mod tools;
use std::collections::HashMap;
@ -95,8 +97,11 @@ const MAX_BOOKMARK_PAGE: usize = 1000;
const FIND_FIRST_BATCH: usize = 8;
const FIND_MAX_BATCH: usize = 128;
/// Page texts of one document version: (the working bytes, one slot per page).
type TextCache = (Arc<Vec<u8>>, Vec<Option<Arc<PageText>>>);
use text_cache::TextCache;
type PageTexts = Vec<(usize, std::result::Result<Arc<PageText>, String>)>;
const FIND_MAX_WARNINGS: usize = 100;
const FIND_MAX_WARNING_BYTES: usize = 1024;
/// A headless PdfCraft session driven by tool calls.
pub struct Automation {
@ -104,8 +109,8 @@ pub struct Automation {
root: Option<PathBuf>,
/// Synchronous renderers, rebuilt when a document's working bytes change.
renderers: HashMap<DocId, (Arc<Vec<u8>>, PageRenderer)>,
/// Extracted page text per document version (the working bytes it was taken from).
texts: HashMap<DocId, TextCache>,
/// Extracted page text, under one memory budget across all open documents.
texts: TextCache,
}
impl Default for Automation {
@ -116,7 +121,7 @@ impl Default for Automation {
impl Automation {
pub fn new() -> Self {
Self { session: Session::new(), root: None, renderers: HashMap::new(), texts: HashMap::new() }
Self { session: Session::new(), root: None, renderers: HashMap::new(), texts: TextCache::default() }
}
/// Confine every path the tools read or write to `root` (relative paths resolve inside it).
@ -165,6 +170,7 @@ impl Automation {
let a = Args(args);
let out = match name {
"doc_open" => self.doc_open(&a)?,
"doc_batch" => self.doc_batch(&a)?,
"doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::<Vec<_>>() }),
"doc_info" => {
let id = self.doc(&a)?.id;
@ -717,6 +723,7 @@ impl Automation {
"sign_list" => self.sign_list(&a)?,
"accessibility_check" => self.a11y_check(&a)?,
"accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?,
"accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?,
"ocr_recognize" => self.ocr_recognize(&a)?,
"js_run" => self.js_run(&a)?,
"js_document_scripts" => self.js_document_scripts(&a)?,
@ -851,6 +858,10 @@ impl Automation {
}
fn doc_save(&mut self, a: &Args) -> Result<Value> {
self.doc_save_with(a, write_atomic)
}
fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result<Value> {
let doc = self.doc(a)?;
let id = doc.id;
let target = match a.opt_str("path")? {
@ -868,7 +879,7 @@ impl Automation {
// A save that adds or removes encryption, or must not keep the old revision (Sanitize,
// redaction), is rewritten in full even when an incremental save was asked for.
let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes());
write_atomic(&target, &bytes)?;
write(&target, &bytes)?;
let path = target.to_string_lossy().into_owned();
self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?;
Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(self.doc(a)?) }))
@ -1702,23 +1713,47 @@ impl Automation {
/// The text of `pages` (0-based), from the cache or extracted in parallel.
fn page_texts(&mut self, id: DocId, pages: &[usize]) -> Result<Vec<Arc<PageText>>> {
self.page_text_results(id, pages, &mut extract_parallel)?
.into_iter()
.map(|(p, text)| text.map_err(|e| failed(format!("page {}: {e}", p + 1))))
.collect()
}
/// Keep per-page failures until the caller chooses strict or partial-result semantics.
fn page_text_results(
&mut self,
id: DocId,
pages: &[usize],
extract: &mut impl FnMut(&Arc<Vec<u8>>, Option<Arc<str>>, &[usize]) -> PageTexts,
) -> Result<PageTexts> {
let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?;
let (bytes, n) = (doc.bytes.clone(), doc.info.pages.len());
let password: Option<Arc<str>> = doc.password.as_deref().map(Arc::from);
let entry = self.texts.entry(id).or_insert_with(|| (bytes.clone(), Vec::new()));
if !Arc::ptr_eq(&entry.0, &bytes) || entry.1.len() != n {
*entry = (bytes.clone(), vec![None; n]);
}
let mut missing: Vec<usize> = pages.iter().copied().filter(|p| entry.1.get(*p).is_some_and(Option::is_none)).collect();
missing.sort_unstable();
missing.dedup();
for (p, text) in extract_parallel(&bytes, password, &missing) {
let text = Arc::new(text.map_err(|e| failed(format!("page {}: {e}", p + 1)))?);
if let Some(slot) = entry.1.get_mut(p) {
*slot = Some(text);
self.texts.prepare(id, &bytes);
let mut results = HashMap::new();
let mut missing = Vec::new();
for &p in pages {
if p >= n {
results.insert(p, Err("page is out of range".to_owned()));
} else if let Some(text) = self.texts.get(id, p) {
results.insert(p, Ok(text));
} else {
missing.push(p);
}
}
pages.iter().map(|p| entry.1.get(*p).cloned().flatten().ok_or_else(|| failed(format!("page {} has no text", p + 1)))).collect()
missing.sort_unstable();
missing.dedup();
if !missing.is_empty() {
for (p, text) in extract(&bytes, password, &missing) {
if let Ok(text) = &text {
self.texts.insert(id, p, &bytes, text.clone());
}
results.insert(p, text);
}
}
// The batch owns its Arcs: evicting a cache entry cannot lose a requested result. A
// worker that fails to return a page is an explicit failure, never an empty success.
Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect())
}
fn text_extract(&mut self, a: &Args) -> Result<Value> {
@ -1741,9 +1776,13 @@ impl Automation {
/// Matches in document order, extracting page text in growing batches and stopping as soon as
/// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document
/// (#971). One match past the limit is looked for, so `complete` is true only when no more
/// exist; otherwise `next` holds the arguments that continue the search.
/// (#971). One match past the limit supplies `next`. Reached extraction failures are reported
/// as bounded warnings; `complete` requires both no continuation and no failed pages.
fn text_find(&mut self, a: &Args) -> Result<Value> {
self.text_find_with(a, &mut extract_parallel)
}
fn text_find_with(&mut self, a: &Args, extract: &mut impl FnMut(&Arc<Vec<u8>>, Option<Arc<str>>, &[usize]) -> PageTexts) -> Result<Value> {
let doc = self.doc(a)?;
let (id, n) = (doc.id, doc.info.pages.len());
let query = a.str("query")?;
@ -1756,13 +1795,28 @@ impl Automation {
let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX);
let mut matches = Vec::new();
let mut next = None;
let mut warnings = Vec::new();
let mut warning_count = 0usize;
let (mut first, mut batch) = (start, FIND_FIRST_BATCH);
'search: while first < n {
let end = first.saturating_add(batch).min(n);
let pages: Vec<usize> = (first..end).collect();
let texts = self.page_texts(id, &pages)?;
for (p, text) in pages.iter().zip(&texts) {
let skip_here = if *p == start { skip } else { 0 };
let texts = self.page_text_results(id, &pages, extract)?;
for (p, text) in texts {
let text = match text {
Ok(text) => text,
Err(mut error) => {
warning_count += 1;
if warnings.len() < FIND_MAX_WARNINGS {
let end = error.floor_char_boundary(FIND_MAX_WARNING_BYTES.min(error.len()));
let truncated = end < error.len();
error.truncate(end);
warnings.push(json!({ "page": p + 1, "error": error, "error_truncated": truncated }));
}
continue;
}
};
let skip_here = if p == start { skip } else { 0 };
// One extra match proves that the caller needs a continuation. Include skipped
// matches on the starting page when bounding the temporary per-page result.
let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1);
@ -1777,7 +1831,8 @@ impl Automation {
first = end;
batch = batch.saturating_mul(2).min(FIND_MAX_BATCH);
}
let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none(), "matches": matches });
let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches,
"warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings });
if let (Some(next), Some(o)) = (next, out.as_object_mut()) {
o.insert("next".into(), next);
}
@ -2154,8 +2209,13 @@ fn info(d: &Document) -> Value {
})).collect::<Vec<_>>(),
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::<Vec<_>>(),
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
// `substitute` names the face standing in for a program the document does not embed, and
// `substitution` says why. Both are null for an embedded font: nothing stands in. A null
// `substitute` with a non-null `substitution` means no face was available at all, so
// nothing is drawn for that font -- which is the answer to "why is my text missing?".
"fonts": i.fonts.iter().map(|f| json!({
"name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding,
"substitute": f.substitute, "substitution": f.substitution,
})).collect::<Vec<_>>(),
"warnings": i.warnings,
"repairs": d.repair_log(),
@ -2193,12 +2253,12 @@ fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>>
}
/// Extract the text of `pages` (0-based) with one renderer per worker thread.
fn extract_parallel(bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[usize]) -> Vec<(usize, std::result::Result<PageText, String>)> {
fn extract_parallel(bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[usize]) -> PageTexts {
let extract = |r: &mut PageRenderer, p: usize| {
let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false });
match out.error {
Some(e) => Err(e),
None => Ok(out.text.map(|t| (*t).clone()).unwrap_or_default()),
None => Ok(out.text.unwrap_or_default()),
}
};
let config = RenderConfig { password, ..Default::default() };
@ -2209,7 +2269,7 @@ fn extract_parallel(bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[u
return pages.iter().map(|p| (*p, extract(&mut r, *p))).collect();
}
let next = std::sync::atomic::AtomicUsize::new(0);
let mut out: Vec<(usize, std::result::Result<PageText, String>)> = std::thread::scope(|s| {
let mut out: PageTexts = std::thread::scope(|s| {
let handles: Vec<_> = (0..workers)
.map(|_| {
s.spawn(|| {
@ -2250,13 +2310,24 @@ fn unused(dir: &Path, stem: &str) -> PathBuf {
(2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first)
}
/// Write via a temporary file in the same directory, then rename over the target.
/// Write and sync a temporary file in the same directory, then rename over the target.
/// This flushes staged contents; it does not promise parent-directory durability after a
/// power loss. Filesystems that refuse sync cause the save to fail before publication.
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
write_atomic_with(path, bytes, staging_suffixes())
}
/// [`write_atomic`], trying the staging names that `suffixes` give.
fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator<Item = u64>) -> Result<()> {
write_atomic_synced(path, bytes, suffixes, std::fs::File::sync_all)
}
fn write_atomic_synced(
path: &Path,
bytes: &[u8],
suffixes: impl IntoIterator<Item = u64>,
sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>,
) -> Result<()> {
use std::io::Write;
// A folder can't be replaced, and staging beside it could land outside the root: "." names
// the root itself, whose parent isn't ours to write in.
@ -2271,7 +2342,9 @@ fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator<Item
// Closed at the end of the block, before the rename.
let written = {
let mut file = file;
file.write_all(bytes)
// Surface delayed writeback failures before replacing the destination or marking
// a document clean. The handle is closed before rename (required on Windows).
file.write_all(bytes).and_then(|()| sync(&file))
};
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
@ -2321,6 +2394,118 @@ mod tests {
}
}
fn search_document(a: &mut Automation, pages: usize) -> DocId {
a.session.open("synthetic.pdf", None, Arc::new(numbered_pages(pages)), None).unwrap()
}
#[test]
fn search_cache_stays_bounded_reextracts_and_follows_edits_and_close() {
let mut a = Automation::new();
a.texts = TextCache::new(16 * 1024);
let first = search_document(&mut a, 200);
let second = search_document(&mut a, 200);
for id in [first, second] {
let result = json(&mut a, "text_find", json!({ "doc": id.0, "query": "absent" }));
assert_eq!(result["complete"], true);
assert!(a.texts.bytes() <= 16 * 1024);
assert!(a.texts.len() < 200, "an exhaustive search must not retain every page");
let found = json(&mut a, "text_find", json!({ "doc": id.0, "query": "Page", "limit": 1000 }));
assert_eq!(found["count"], 200, "eviction inside a batch must not discard its matches");
eprintln!("200 pages searched: {} resident pages, {} estimated bytes (budget 16384)", a.texts.len(), a.texts.bytes());
}
assert!(a.texts.get(first, 0).is_none());
let text = a.page_texts(first, &[0]).unwrap().remove(0);
assert_eq!(text.plain_text(), "Page 1");
let again = a.page_texts(first, &[0]).unwrap().remove(0);
assert!(Arc::ptr_eq(&text, &again), "cache hits reuse glyph storage");
assert!(a.texts.bytes() <= 16 * 1024);
json(&mut a, "page_delete", json!({ "doc": first.0, "pages": [1] }));
let edited = a.page_texts(first, &[0]).unwrap().remove(0);
assert_eq!(edited.plain_text(), "Page 2");
assert!(!Arc::ptr_eq(&text, &edited));
json(&mut a, "doc_close", json!({ "doc": first.0, "discard_changes": true }));
json(&mut a, "doc_close", json!({ "doc": second.0 }));
assert_eq!((a.texts.bytes(), a.texts.len()), (0, 0));
}
#[test]
fn strict_text_collection_refuses_partial_results_but_keeps_successful_pages() {
let mut a = Automation::new();
let id = search_document(&mut a, 3);
let error = a.page_texts(id, &[0, 3, 1]).unwrap_err();
assert!(error.to_string().contains("page 4: page is out of range"));
assert!(a.texts.get(id, 0).is_some());
assert!(a.texts.get(id, 1).is_some(), "successful extraction after the failed entry is retained");
let results = a.page_text_results(id, &[2], &mut |_, _, _| vec![]).unwrap();
assert!(results[0].1.is_err(), "a missing worker result must not become an empty page");
}
#[test]
fn search_keeps_matches_around_failed_pages_and_continuations() {
let mut a = Automation::new();
let id = search_document(&mut a, 16);
let mut extract = |bytes: &Arc<Vec<u8>>, password, pages: &[usize]| {
extract_parallel(bytes, password, pages)
.into_iter()
.map(|(p, result)| (p, if [2, 6].contains(&p) { Err(format!("synthetic page {} failure", p + 1)) } else { result }))
.collect()
};
let all = a.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page" })), &mut extract).unwrap();
assert_eq!(all["count"], 14);
assert_eq!(all["complete"], false);
assert_eq!(all["warning_count"], 2);
assert_eq!(all["warnings"][0]["page"], 3);
assert_eq!(all["warnings"][1]["page"], 7);
assert!(all.get("next").is_none());
assert_eq!(all["matches"][13]["page"], 16);
assert!(a.texts.get(id, 7).is_some(), "success after a failure in the same batch is cached");
let mut args = json!({ "doc": id.0, "query": "Page", "limit": 4 });
let mut matches = Vec::new();
let mut warned = Vec::new();
loop {
let part = a.text_find_with(&Args(&args), &mut extract).unwrap();
matches.extend(part["matches"].as_array().unwrap().iter().cloned());
warned.extend(part["warnings"].as_array().unwrap().iter().map(|w| w["page"].clone()));
if !part["warnings"].as_array().unwrap().is_empty() {
assert_eq!(part["complete"], false);
}
let Some(next) = part.get("next") else { break };
args["start_page"] = next["start_page"].clone();
args["skip"] = next["skip"].clone();
}
assert_eq!(matches, *all["matches"].as_array().unwrap(), "no readable match is lost or repeated");
assert_eq!(warned, vec![json!(3), json!(7)]);
}
#[test]
fn search_bounds_warning_volume_and_does_not_reach_distant_failures() {
let mut a = Automation::new();
let id = search_document(&mut a, 200);
let first = a
.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page", "limit": 1 })), &mut |bytes, password, pages| {
assert!(pages.iter().all(|p| *p < FIND_FIRST_BATCH), "a bounded search must not reach the bad page at the end");
extract_parallel(bytes, password, pages)
})
.unwrap();
assert_eq!(first["count"], 1);
assert_eq!(first["warning_count"], 0);
assert_eq!(first["next"], json!({ "start_page": 2, "skip": 0 }));
a.texts.remove(&id);
let failures = a
.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page" })), &mut |_, _, pages| {
pages.iter().map(|p| (*p, Err("界".repeat(2000)))).collect()
})
.unwrap();
assert_eq!(failures["count"], 0);
assert_eq!(failures["complete"], false);
assert!(failures.get("next").is_none());
assert_eq!(failures["warning_count"], 200);
assert_eq!(failures["warnings_truncated"], true);
let warnings = failures["warnings"].as_array().unwrap();
assert_eq!(warnings.len(), FIND_MAX_WARNINGS);
assert!(warnings.iter().all(|w| w["error"].as_str().unwrap().len() <= FIND_MAX_WARNING_BYTES && w["error_truncated"] == true));
}
#[test]
fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() {
let dir = std::env::temp_dir().join(format!("pdfcraft-automation-find-{}", std::process::id()));
@ -2329,7 +2514,7 @@ mod tests {
std::fs::write(dir.join("long.pdf"), numbered_pages(200)).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = json(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].clone();
let extracted = |a: &Automation| a.texts.values().map(|(_, pages)| pages.iter().filter(|t| t.is_some()).count()).sum::<usize>();
let extracted = |a: &Automation| a.texts.len();
// #971: the first match is on page 1 and the next on page 10, so the rest of the 200 pages
// aren't extracted or cached.
@ -2517,6 +2702,79 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_failed_staging_sync_keeps_the_destination_and_cleans_up() {
for exists in [false, true] {
let dir = staging_dir(if exists { "sync-existing" } else { "sync-new" });
let target = dir.join("out.pdf");
if exists {
std::fs::write(&target, "OLD").unwrap();
}
let result = write_atomic_synced(&target, b"NEW", [1], |_| {
assert_eq!(std::fs::read(staged(&dir, 1)).unwrap(), b"NEW", "sync follows writing");
if exists {
assert_eq!(read(&target), "OLD", "sync precedes publication");
}
Err(std::io::Error::other("injected storage sync failure"))
});
assert!(result.is_err(), "sync failure must refuse publication");
assert!(result.unwrap_err().to_string().contains("injected storage sync failure"));
if exists {
assert_eq!(read(&target), "OLD");
assert_eq!(listing(&dir), ["out.pdf"]);
} else {
assert!(!target.exists());
assert!(listing(&dir).is_empty());
}
std::fs::remove_dir_all(dir).unwrap();
}
}
#[test]
fn a_failed_save_sync_keeps_the_document_dirty_and_can_be_retried() {
let dir = staging_dir("sync-dirty");
let target = dir.join("out.pdf");
let mut a = Automation::new().with_root(&dir).unwrap();
let id = search_document(&mut a, 2);
json(&mut a, "doc_save", json!({"doc": id.0, "path": "out.pdf"}));
let old = std::fs::read(&target).unwrap();
json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90}));
let args = json!({"doc": id.0});
let before = summary(a.doc(&Args(&args)).unwrap());
let result = a.doc_save_with(&Args(&args), |path, bytes| {
write_atomic_synced(path, bytes, [1], |_| Err(std::io::Error::other("injected storage sync failure")))
});
assert!(result.is_err());
let doc = a.doc(&Args(&args)).unwrap();
assert!(doc.dirty);
assert_eq!(summary(doc), before, "failed save must not reset history or saved state");
assert_eq!(std::fs::read(&target).unwrap(), old);
assert_eq!(listing(&dir), ["out.pdf"]);
json(&mut a, "doc_save", args.clone());
assert!(!a.doc(&Args(&args)).unwrap().dirty);
assert_ne!(std::fs::read(&target).unwrap(), old);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn successful_staging_sync_precedes_publication() {
let dir = staging_dir("sync-success");
let target = dir.join("out.pdf");
std::fs::write(&target, "OLD").unwrap();
let mut called = false;
write_atomic_synced(&target, b"NEW", [1], |file| {
called = true;
assert_eq!(read(&target), "OLD");
assert_eq!(std::fs::read(staged(&dir, 1)).unwrap(), b"NEW");
file.sync_all()
})
.unwrap();
assert!(called);
assert_eq!(read(&target), "NEW");
assert_eq!(listing(&dir), ["out.pdf"]);
std::fs::remove_dir_all(dir).unwrap();
}
/// The names in a folder: what a test can see was left behind.
fn listing(dir: &Path) -> Vec<String> {
let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();

View File

@ -1,7 +1,9 @@
//! 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, read_remote_document, resolve_remote_document_path};
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;
@ -106,6 +108,12 @@ impl Automation {
(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();

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

@ -65,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),

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,7 +156,7 @@ 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()
@ -170,10 +181,10 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return each match, in page order, with its page and line rectangles in points (origin top-left). Pages are read only until the limit is reached.")
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). The search stops once it has this many; `complete` says whether every match was returned, and when it is false `next` holds the start_page and skip that continue the search." },
.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.")
@ -550,9 +561,9 @@ 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.")
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." } }),
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.")
@ -865,6 +876,8 @@ pub fn tools() -> Vec<ToolDef> {
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")

View File

@ -145,6 +145,32 @@ fn autotag_preview_proposes_text_without_changing_the_pdf() {
assert!(preview["candidates"].as_array().unwrap().iter().any(|c| c["text"] == "First paragraph" && c["type"] == "P"));
}
#[test]
fn autotag_apply_writes_reviewable_tags_and_survives_save_reopen() {
let dir = workdir("autotag-apply");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
let before = ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["tagged-pdf", "tagged-content"] }));
assert_eq!(before["failed"], 2);
let rendered_before = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
let applied = ok(&mut a, "accessibility_autotag_apply", json!({ "doc": doc }));
assert_eq!(applied["count"], 3, "Document plus one paragraph per page");
assert_eq!(applied["tags"][1]["type"], "P");
let after = ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["tagged-pdf", "tagged-content"] }));
assert_eq!(after["passed"], 2);
assert_eq!(a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(), rendered_before);
assert!(matches!(a.call("accessibility_autotag_apply", &json!({ "doc": doc })), Err(ToolError::Failed(_))));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "tagged.pdf" }));
let saved = std::fs::read(dir.join("tagged.pdf")).unwrap();
let reopened = pdfcraft_cos::Document::open(std::sync::Arc::new(saved)).unwrap();
let pages = pdfcraft_model::pages(&reopened);
assert_eq!(pages.len(), 2);
assert!(pages.iter().all(|page| page.dict.contains(b"StructParents")));
assert!(reopened.get(reopened.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot"));
}
/// A one-page PDF whose form holds one text field named `name`.
fn form_pdf(name: &str) -> Vec<u8> {
let objs: Vec<String> = vec![
@ -321,6 +347,43 @@ fn arabic_actual_text_stays_logical_through_editor_tools_and_save_reopen() {
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn arabic_ligature_actual_text_is_one_editable_word_through_save_reopen() {
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
use std::sync::Arc;
let dir = workdir("arabic-ligature-actual-text");
let mut doc = Document::open(Arc::new(fixture(1))).unwrap();
// Synthetic glyph codes: A س, B م, X lam-alef. No installed Arabic font is needed.
let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \
1 begincodespacerange <00> <FF> endcodespacerange 3 beginbfchar \
<41> <0633> <42> <0645> <58> <FEFB> \
endbfchar endcmap CMapName currentdict /CMap defineresource pop end end";
let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"ToUnicode".to_vec(), Object::Ref(unicode))).unwrap();
let content = "BT /F1 24 Tf 20 150 Td (B) Tj /Span <</ActualText <FEFF06440627>>> BDC (X) Tj EMC (A) Tj ET";
doc.set(ObjRef::new(5, 0), Object::Stream(Stream::flate(Dict::new(), content.as_bytes())));
std::fs::write(dir.join("arabic.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap();
let mut a = auto(&dir);
let id = ok(&mut a, "doc_open", json!({"path":"arabic.pdf"}))["doc"].as_u64().unwrap();
let lines = ok(&mut a, "text_lines", json!({"doc":id,"page":1}));
assert_eq!(lines["lines"].as_array().unwrap().len(), 1);
assert_eq!(lines["lines"][0]["text"], "سلام");
assert_eq!(page_text(&mut a, id), ["سلام"]);
assert_eq!(ok(&mut a, "text_find", json!({"doc":id,"query":"سلام"}))["matches"].as_array().unwrap().len(), 1);
let render = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap();
ok(&mut a, "doc_save", json!({"doc":id,"path":"saved.pdf"}));
let re = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "text_lines", json!({"doc":re,"page":1})), lines);
assert_eq!(a.call("page_render", &json!({"doc":re,"page":1,"dpi":72})).unwrap(), render);
ok(&mut a, "text_edit", json!({"doc":re,"page":1,"paragraph":1,"text":"Replacement"}));
ok(&mut a, "doc_save", json!({"doc":re,"path":"edited.pdf"}));
let edited = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap();
assert_eq!(page_text(&mut a, edited), ["Replacement"]);
std::fs::remove_dir_all(dir).unwrap();
}
/// Each of these writes to its required `path` and replaces an existing file there, so the MCP
/// annotations must not tell clients the call is read-only or harmless (#130).
#[test]
@ -346,6 +409,13 @@ fn open_inspect_render_and_find() {
assert_eq!(info["pages"][0]["width"], 200.0);
assert_eq!(info["pages"][1]["label"], "2");
// The fixture names /Helvetica without embedding it, so an agent can see both that a face is
// standing in and why that face. `substitution` is null only when nothing stands in.
let font = &info["fonts"][0];
assert_eq!((&font["name"], &font["embedded"]), (&json!("Helvetica"), &json!(false)));
assert_eq!(font["substitute"], json!("Helvetica"));
assert!(font["substitution"].as_str().is_some_and(|w| w.contains("standard 14")), "{font}");
assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"]);
let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "page 2" }));
@ -3423,7 +3493,12 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() {
assert_eq!(a.session().get(pdfcraft_engine::DocId(target_id)).unwrap().info.pages.len(), 3);
let absolute = ok(&mut a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap();
let opened = ok(&mut a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1 }));
let before = a.session().docs().len();
for args in [json!({ "doc": absolute, "page": 1, "index": 1 }), json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": false })] {
assert!(matches!(err(&mut a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute")));
assert_eq!(a.session().docs().len(), before);
}
let opened = ok(&mut a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true }));
assert_eq!(opened["path"], "target.pdf");
assert_eq!(opened["destination"]["page"], 2);
@ -5560,3 +5635,47 @@ fn private_crops_with_nested_state_resize_save_reopen_through_tools() {
assert_eq!(ok(&mut a, "page_images", json!({"doc":doc,"page":1})), original);
}
}
#[test]
fn absolute_gotor_consent_does_not_bypass_validation_or_passwords() {
let (base, root) = sandbox("gotor-absolute-consent");
std::fs::create_dir_all(root.join("folder.pdf")).unwrap();
std::fs::write(root.join("notes.txt"), fixture(1)).unwrap();
std::fs::write(root.join("encrypted.pdf"), protected_fixture("secret")).unwrap();
link_dir(&root, "outside-link", &base.join("outside"));
let mut a = auto(&root);
for (name, target, reason) in [
("missing", root.join("missing.pdf"), "missing.pdf"),
("folder", root.join("folder.pdf"), "not a regular file"),
("text", root.join("notes.txt"), "not a .pdf file"),
("outside", base.join("outside/secret.pdf"), "outside"),
("symlink", root.join("outside-link/secret.pdf"), "outside"),
] {
let path = format!("{name}-source.pdf");
std::fs::write(root.join(&path), remote_link_pdf(target.to_str().unwrap(), Some("[0 /Fit]"))).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap();
let before = a.session().docs().len();
// The absence of a target or a root escape must not be probed before consent.
assert!(
matches!(err(&mut a, "link_open", json!({ "doc": doc, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("allow_absolute"))
);
assert!(
matches!(err(&mut a, "link_open", json!({ "doc": doc, "page": 1, "index": 1, "allow_absolute": true })), ToolError::Failed(m) if m.contains(reason)),
"{name}"
);
assert_eq!(a.session().docs().len(), before);
}
std::fs::write(root.join("password-source.pdf"), remote_link_pdf(root.join("encrypted.pdf").to_str().unwrap(), Some("[0 /Fit]"))).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap();
let before = a.session().docs().len();
for password in ["", "wrong"] {
assert!(matches!(
err(&mut a, "link_open", json!({ "doc": doc, "page": 1, "index": 1, "allow_absolute": true, "password": password })),
ToolError::Failed(_)
));
assert_eq!(a.session().docs().len(), before);
}
let opened = ok(&mut a, "link_open", json!({ "doc": doc, "page": 1, "index": 1, "allow_absolute": true, "password": "secret" }));
assert_eq!(opened["document"]["encrypted"], true);
assert_eq!(opened["destination_applied"], true);
}

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

@ -105,6 +105,8 @@ pub struct Document {
entries: Arc<BTreeMap<u32, XrefEntry>>,
trailer: Dict,
revisions: Arc<Vec<Revision>>,
/// See [`Self::xref_oddities`].
xref_oddities: Arc<Vec<(u64, u32)>>,
repair_log: Arc<Vec<String>>,
cache: Arc<Mutex<HashMap<u32, Arc<Object>>>>,
objstms: Arc<Mutex<HashMap<u32, Arc<ObjStm>>>>,
@ -219,6 +221,7 @@ impl Document {
entries: Arc::new(BTreeMap::new()),
trailer: Dict::new(),
revisions: Arc::new(Vec::new()),
xref_oddities: Arc::default(),
repair_log: Arc::new(Vec::new()),
cache: Arc::default(),
objstms: Arc::default(),
@ -282,6 +285,7 @@ impl Document {
entries: Arc::new(BTreeMap::new()),
trailer: Dict::new(),
revisions: Arc::new(Vec::new()),
xref_oddities: Arc::default(),
repair_log: Arc::new(Vec::new()),
cache: Arc::default(),
objstms: Arc::default(),
@ -304,10 +308,11 @@ impl Document {
let parsed = doc.read_xref_chain(&mut log);
let mut authenticated = false;
let ok = match parsed {
Ok((entries, trailer, revisions)) => {
Ok((entries, trailer, revisions, oddities)) => {
doc.entries = Arc::new(entries);
doc.trailer = trailer;
doc.revisions = Arc::new(revisions);
doc.xref_oddities = Arc::new(oddities);
// Authenticate before checking the catalog: it may sit in an encrypted object
// stream, which reads as garbage until the key is known.
if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() {
@ -587,6 +592,36 @@ impl Document {
self.entries.get(&num).cloned()
}
/// Cross-reference entries this reader did not take at face value, as (byte offset of the
/// section, object number): an in-use entry at offset 0, a second and different entry for
/// one object in the same section, a reserved cross-reference stream entry type, or a
/// hybrid /XRefStm entry that disagrees with the table. The reader sets such an entry aside
/// and keeps what it already had for the object. Another reader, the page renderer among
/// them, may follow it instead, so the two can then show different objects for one number.
/// Code that compares revisions (signature validation) must not call such an object
/// unchanged.
pub fn xref_oddities(&self) -> &[(u64, u32)] {
&self.xref_oddities
}
/// Whether the cross-reference entry of `num` locates that object itself: its `num G obj`
/// header at exactly the recorded offset, or the member with that number at the recorded
/// position of its object stream. When it does not, [`Self::get`] still finds the object by
/// searching, but another reader may resolve the number differently. Ignores unsaved edits.
pub fn xref_entry_is_exact(&self, num: u32) -> bool {
match self.entries.get(&num) {
None | Some(XrefEntry::Free { .. }) => true,
Some(XrefEntry::InFile { offset, .. }) => {
let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num);
// Offsets may count from a header that does not start the file.
usize::try_from(*offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset))))
}
Some(XrefEntry::InStream { stream, index }) => {
self.objstm(*stream).is_ok_and(|stm| usize::try_from(*index).ok().and_then(|i| stm.index.get(i)).is_some_and(|(n, _)| *n == num))
}
}
}
/// Object `num` has an unsaved edit.
pub fn is_edited(&self, num: u32) -> bool {
self.overlay.contains_key(&num)
@ -951,7 +986,7 @@ impl Document {
}
#[allow(clippy::type_complexity)]
fn read_xref_chain(&self, log: &mut Vec<String>) -> Result<(BTreeMap<u32, XrefEntry>, Dict, Vec<Revision>), CosError> {
fn read_xref_chain(&self, log: &mut Vec<String>) -> Result<(BTreeMap<u32, XrefEntry>, Dict, Vec<Revision>, Vec<(u64, u32)>), CosError> {
let data = &self.data;
let tail_start = data.len().saturating_sub(4096);
let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?;
@ -963,6 +998,7 @@ impl Document {
let mut entries = BTreeMap::new();
let mut trailer: Option<Dict> = None;
let mut revisions = Vec::new();
let mut oddities = Vec::new();
let mut visited = HashSet::new();
let mut next = Some(first);
while let Some(off) = next.take() {
@ -972,12 +1008,15 @@ impl Document {
}
// This section's own entries; older sections only fill what newer ones left out.
let mut sect = BTreeMap::new();
let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect) {
// Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]).
let mut odd = Vec::new();
let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd) {
Ok(v) => v,
Err(e) if self.header_offset > 0 => {
log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header"));
sect.clear();
self.read_section(off as usize + self.header_offset, &mut sect)?
odd.clear();
self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)?
}
Err(e) => {
if revisions.is_empty() {
@ -992,17 +1031,23 @@ impl Document {
// free entries it gives objects only a cross-reference stream can locate.
if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) {
let mut stm = BTreeMap::new();
match self.read_section(x as usize, &mut stm) {
match self.read_section(x as usize, &mut stm, &mut odd) {
Ok(_) => {
for (num, e) in stm {
if sect.get(&num).is_none_or(|old| matches!(old, XrefEntry::Free { .. })) {
sect.insert(num, e);
match sect.get(&num) {
None | Some(XrefEntry::Free { .. }) => {
sect.insert(num, e);
}
// The table already places the object somewhere else.
Some(old) if *old != e => odd.push(num),
Some(_) => {}
}
}
}
Err(e) => log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")),
}
}
oddities.extend(odd.into_iter().map(|num| (off, num)));
for (num, e) in sect {
entries.entry(num).or_insert(e);
}
@ -1016,11 +1061,12 @@ impl Document {
for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] {
trailer.remove(k);
}
Ok((entries, trailer, revisions))
Ok((entries, trailer, revisions, oddities))
}
/// Read one section at `off`, adding entries not already known (newer sections win).
fn read_section(&self, off: usize, entries: &mut BTreeMap<u32, XrefEntry>) -> Result<(Dict, bool), CosError> {
/// Read one section at `off`, adding entries not already known (newer sections win). `odd`
/// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]).
fn read_section(&self, off: usize, entries: &mut BTreeMap<u32, XrefEntry>, odd: &mut Vec<u32>) -> Result<(Dict, bool), CosError> {
let data = &self.data;
let mut lx = Lexer::new(data, off);
if lx.eat_keyword(b"xref") {
@ -1052,11 +1098,17 @@ impl Document {
(b"f", _) => XrefEntry::Free { next_generation: generation },
_ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }),
};
// Offset 0 "in use" entries are a known producer bug: ignore them.
// Offset 0 "in use" entries are a known producer bug: ignore them. (Object 0
// is never a real object, so its entry cannot mislead anyone.)
if matches!(entry, XrefEntry::InFile { offset: 0, .. }) {
if num != 0 {
odd.push(num);
}
continue;
}
entries.entry(num).or_insert(entry);
if *entries.entry(num).or_insert(entry) != entry {
odd.push(num);
}
}
}
}
@ -1110,12 +1162,21 @@ impl Document {
0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 },
1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 },
2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 },
_ => continue, // reserved types are treated as null references (§7.5.8.3)
// Reserved types are treated as null references (§7.5.8.3).
_ => {
odd.push(num);
continue;
}
};
if matches!(entry, XrefEntry::InFile { offset: 0, .. }) {
if num != 0 {
odd.push(num);
}
continue;
}
entries.entry(num).or_insert(entry);
if *entries.entry(num).or_insert(entry) != entry {
odd.push(num);
}
}
}
Ok((s.dict.clone(), true))
@ -1263,6 +1324,44 @@ mod tests {
out
}
/// Entries the reader sets aside are remembered, so a caller comparing revisions can tell
/// that another reader may resolve the object differently.
#[test]
fn cross_reference_entries_that_are_set_aside_are_recorded() {
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
let plain = Document::open(Arc::new(base.clone())).unwrap();
assert!(plain.xref_oddities().is_empty());
assert!((1..=3).all(|n| plain.xref_entry_is_exact(n)));
let prev = String::from_utf8_lossy(&base).rfind("xref\n0 4").unwrap();
let third = plain.xref_entry(3).unwrap();
// A later section: object 3 "in use" at offset 0, object 2 listed twice with different
// offsets, and object 0 at offset 0 (the usual producer slip, which names no object).
let mut bytes = base.clone();
let section = bytes.len();
bytes.extend_from_slice(
format!("xref\n0 1\n0000000000 65535 n \n3 1\n0000000000 00000 n \n2 1\n0000000058 00000 n \n2 1\n0000000009 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.xref_oddities(), [(section as u64, 3), (section as u64, 2)]);
// What the reader resolves is what it resolved before: the first entry it kept.
assert_eq!(doc.xref_entry(3), Some(third));
assert_eq!(doc.xref_entry(2), Some(XrefEntry::InFile { offset: 58, generation: 0 }));
}
/// An entry that misses its object still resolves (by searching) but is not exact.
#[test]
fn an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact() {
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
let text = String::from_utf8_lossy(&base).into_owned();
let at = text.find("3 0 obj").unwrap();
let bytes = text.replacen(&format!("{at:010} 00000 n"), &format!("{:010} 00000 n", at + 2), 1).into_bytes();
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.get(ObjRef { num: 3, generation: 0 }).as_string().map(|s| s.bytes.clone()), Some(b"three".to_vec()));
assert!(!doc.xref_entry_is_exact(3));
assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2));
}
#[test]
fn invalid_xref_object_ranges_are_reconstructed() {
// Original in-memory PDF: an intact catalog followed by a damaged xref section.

View File

@ -15,6 +15,8 @@ pdfcraft-fonts = { workspace = true }
png = "0.18"
tiff = "0.11"
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
# Already in the graph through image; read directly for CMYK JPEG samples, which image converts to RGB.
zune-jpeg = "0.5"
thiserror = { workspace = true }
# Already in the graph through lopdf; used only to read plain-text files (decode_text).
encoding_rs = "0.8"

View File

@ -1,6 +1,6 @@
//! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages
//! use, as files. JPEG images are written unchanged; other images, and JPEG images with
//! transparency, are decoded and written as PNG with their soft mask, stencil mask or colour
//! transparency or with CMYK samples a viewer would read inverted, are decoded and written as PNG with their soft mask, stencil mask or colour
//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, CCITT, separations) are
//! reported, never silently left out.
@ -126,13 +126,24 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
// A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2).
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) };
let key = if mask { None } else { colour_key(doc, s) };
if jpeg && alpha.is_none() && key.is_none() {
// Decode arrays: only inversion ([1 0] per component) is honoured, the common case.
let inverted = s
.dict
.get(b"Decode")
.map(|d| doc.resolve(d))
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)))
.unwrap_or(false);
// In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image
// viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as
// a negative.
let as_is_negative = jpeg && !inverted && matches!(space(doc, s.dict.get(b"ColorSpace")), Ok(Space::Cmyk));
if jpeg && alpha.is_none() && key.is_none() && !as_is_negative {
return as_is();
}
let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? };
let (samples, bpc) = if jpeg {
// A JPEG decoder gives grey or RGB (CMYK and YCCK are converted); a JPEG that doesn't
// decode, or an indexed one (not valid), is written as is, without its transparency.
// Grey, RGB or CMYK samples (YCCK is converted); a JPEG that doesn't decode, or an
// indexed one (not valid), is written as is, without its transparency.
let Some((samples, decoded)) = jpeg_samples(&s.raw, &space, w, h) else { return as_is() };
space = decoded;
(samples, 8)
@ -153,17 +164,10 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
if alpha.is_none() {
alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k));
}
if jpeg && alpha.is_none() {
if jpeg && alpha.is_none() && !as_is_negative {
return as_is();
}
// Decode arrays: only inversion ([1 0] per component) is honoured, the common case. A JPEG
// decoder has already applied an Adobe JPEG's own inversion.
let invert = s
.dict
.get(b"Decode")
.map(|d| doc.resolve(d))
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)));
let invert = (invert.unwrap_or(false) && !jpeg) ^ mask; // a stencil mask paints its 0 samples
let invert = inverted ^ mask; // a stencil mask paints its 0 samples
let max = (1u32 << bpc.min(8)) - 1;
let mut rgb = Vec::with_capacity(w * h * 3);
for px in samples.chunks_exact(n) {
@ -200,10 +204,13 @@ fn size(w: i64, h: i64) -> Option<(usize, usize)> {
(w > 0 && h > 0 && pixels <= MAX_PIXELS).then_some((w, h))
}
/// A JPEG's samples (8 bits): grey for a grey image, else RGB, with the space they are in.
/// A JPEG's samples (8 bits): grey for a grey image, CMYK as stored for a CMYK one, else RGB,
/// with the space they are in.
fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u8>, Space)> {
if matches!(space, Space::Indexed(..)) {
return None;
match space {
Space::Indexed(..) => return None,
Space::Cmyk => return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)),
_ => {}
}
let img = image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok()?;
if img.width() as usize != w || img.height() as usize != h {
@ -215,6 +222,34 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u
})
}
/// A four-component JPEG's CMYK samples as the page reads them: as stored, not inverted the way
/// a decoder converting to RGB inverts them, with YCCK converted to CMYK.
fn cmyk_jpeg_samples(data: &[u8], w: usize, h: usize) -> Option<Vec<u8>> {
use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};
let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX));
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options);
dec.decode_headers().ok()?;
let input = dec.input_colorspace()?;
if !matches!(input, ColorSpace::CMYK | ColorSpace::YCCK) || dec.dimensions()? != (w, h) {
return None;
}
dec.set_options(options.jpeg_set_out_colorspace(input));
let mut px = dec.decode().ok()?;
if px.len() != w.checked_mul(h)?.checked_mul(4)? {
return None;
}
if input == ColorSpace::YCCK {
for p in px.as_chunks_mut::<4>().0 {
let (y, cb, cr) = (f32::from(p[0]), f32::from(p[1]) - 128.0, f32::from(p[2]) - 128.0);
let ink = |rgb: f32| (255.0 - rgb).round().clamp(0.0, 255.0) as u8;
p[0] = ink(y + 1.402 * cr);
p[1] = ink(y - 0.344_136 * cb - 0.714_136 * cr);
p[2] = ink(y + 1.772 * cb);
}
}
Some(px)
}
/// The colour-key `/Mask` (8.9.6.4): a [min max] pair per colour component, in sample values.
fn colour_key(doc: &Document, s: &Stream) -> Option<Vec<(u32, u32)>> {
let m = doc.resolve(s.dict.get(b"Mask")?);

View File

@ -488,12 +488,21 @@ pub enum SourceKind {
/// File extensions Create converts to PDF (images and plain text).
pub const CONVERTIBLE: [&str; 12] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "txt", "text"];
/// A classic TIFF or a BigTIFF (version 43), in either byte order.
fn is_tiff(bytes: &[u8]) -> bool {
[b"II*\0", b"MM\0*", b"II+\0", b"MM\0+"].iter().any(|m| bytes.starts_with(*m))
}
/// The GIF headers `embed` decodes, so detection and conversion agree.
fn is_gif(bytes: &[u8]) -> bool {
bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")
}
fn is_image(bytes: &[u8]) -> bool {
bytes.starts_with(&[0xFF, 0xD8])
|| bytes.starts_with(b"\x89PNG\r\n\x1a\n")
|| bytes.starts_with(b"II*\0")
|| bytes.starts_with(b"MM\0*")
|| bytes.starts_with(b"GIF8")
|| is_tiff(bytes)
|| is_gif(bytes)
// JPEG 2000: a JP2 file or a raw codestream.
|| bytes.starts_with(JP2_SIGNATURE)
|| bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51])
@ -522,11 +531,11 @@ fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
Ok(vec![jpeg(name, bytes)?])
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Ok(vec![png_image(name, bytes)?])
} else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
} else if is_tiff(bytes) {
tiff_pages(name, bytes)
} else if bytes.starts_with(b"BM") {
Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?])
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
} else if is_gif(bytes) {
Ok(vec![decoded(name, bytes, image::ImageFormat::Gif)?])
} else if bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) {
Ok(vec![jpx(name, bytes)?])

View File

@ -408,6 +408,28 @@ fn source_kind_tells_pdfs_images_and_text_apart() {
assert_eq!(source_kind("b.bmp", b"BM"), None);
assert_eq!(source_kind("a.docx", b"PK\x03\x04"), None);
assert_eq!(source_kind("", b""), None);
// Only the GIF versions Create decodes count as images, so detection never promises a
// conversion that then fails.
assert_eq!(source_kind("a.gif", b"GIF8x not a gif"), None);
assert_eq!(source_kind("notes.txt", b"GIF8 is a word here"), Some(SourceKind::Text));
}
/// BigTIFF (version 43, used for large scans) is detected and converted like a classic TIFF.
#[test]
fn bigtiff_images_become_pages() {
let mut tif = std::io::Cursor::new(Vec::new());
{
let mut enc = tiff::encoder::TiffEncoder::new_big(&mut tif).unwrap();
let mut im = enc.new_image::<tiff::encoder::colortype::Gray8>(4, 2).unwrap();
im.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: 144, d: 1 });
im.write_data(&[128; 8]).unwrap();
}
let tif = tif.into_inner();
assert!(tif.starts_with(b"II+\0"), "the encoder wrote a BigTIFF");
assert_eq!(source_kind("scan.tif", &tif), Some(SourceKind::Image));
let doc = reopen(&from_images(&[("scan.tif".into(), tif)]).unwrap());
assert_eq!(pages(&doc).len(), 1);
assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 2.0, 1.0], "4×2 px at 144 dpi");
}
/// A synthetic ICC profile: a 128-byte header (size, device class, colour space, `acsp`) and
@ -721,3 +743,78 @@ fn damaged_masks_and_sizes_never_panic() {
let _ = image_file(&doc, r);
}
}
/// An 8×8 baseline JPEG of one flat colour with four components, stored as `samples`, and an
/// Adobe APP14 segment with `transform` (0: CMYK, 2: YCCK).
fn flat_four_component_jpeg(samples: [u8; 4], transform: u8) -> Vec<u8> {
fn put(bits: &mut Vec<bool>, value: u32, n: u32) {
bits.extend((0..n).rev().map(|i| value >> i & 1 == 1));
}
let mut v = vec![0xFF, 0xD8];
v.extend_from_slice(&[0xFF, 0xEE, 0, 14, b'A', b'd', b'o', b'b', b'e', 0, 100, 0, 0, 0, 0, transform]);
v.extend_from_slice(&[0xFF, 0xDB, 0, 67, 0]);
v.extend_from_slice(&[1; 64]);
v.extend_from_slice(&[0xFF, 0xC0, 0, 20, 8, 0, 8, 0, 8, 4]);
for id in 1..=4 {
v.extend_from_slice(&[id, 0x11, 0]);
}
// DC table: categories 0 to 11 with four-bit codes equal to the category. AC table: EOB, "0".
v.extend_from_slice(&[0xFF, 0xC4, 0, 31, 0x00, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
v.extend(0..12u8);
v.extend_from_slice(&[0xFF, 0xC4, 0, 20, 0x10, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
v.extend_from_slice(&[0xFF, 0xDA, 0, 14, 4, 1, 0, 2, 0, 3, 0, 4, 0, 0, 63, 0]);
let mut bits = Vec::new();
for s in samples {
// With every quantiser 1, a flat block's DC coefficient is 8 × (sample − 128).
let dc = (i32::from(s) - 128) * 8;
let category = 32 - dc.unsigned_abs().leading_zeros();
put(&mut bits, category, 4);
put(&mut bits, if dc < 0 { (dc + (1 << category) - 1) as u32 } else { dc as u32 }, category);
put(&mut bits, 0, 1);
}
bits.resize(bits.len().div_ceil(8) * 8, true);
for byte in bits.chunks(8).map(|b| b.iter().fold(0u8, |a, &x| a << 1 | u8::from(x))) {
v.push(byte);
if byte == 0xFF {
v.push(0);
}
}
v.extend_from_slice(&[0xFF, 0xD9]);
v
}
#[test]
fn cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows() {
// Issue #1640: in a PDF a CMYK JPEG's samples are ink amounts, inverted only by its /Decode,
// while JPEG decoders and image viewers read them inverted (Adobe's convention for files).
// Exported cyan came out red. YCCK samples (Y 179, Cb 171, Cr 1) are cyan ink too.
let inverting = Object::Array([1, 0, 1, 0, 1, 0, 1, 0].map(Object::Int).to_vec());
for (what, samples, transform, decode, masked) in [
("CMYK", [255, 0, 0, 0], 0, None, true),
("YCCK", [179, 171, 1, 0], 2, None, true),
("inverted CMYK with an inverting /Decode", [0, 255, 255, 255], 0, Some(&inverting), true),
("CMYK without a mask", [255, 0, 0, 0], 0, None, false),
("inverted CMYK with an inverting /Decode, without a mask", [0, 255, 255, 255], 0, Some(&inverting), false),
] {
let jpeg = flat_four_component_jpeg(samples, transform);
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mut d = image_dict(8, 8, "DeviceCMYK", 8);
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
if let Some(decode) = decode {
d.set(b"Decode".to_vec(), decode.clone());
}
if masked {
let m = doc.add(Object::Stream(Stream::from_raw(image_dict(8, 8, "DeviceGray", 8), vec![255; 64])));
d.set(b"SMask".to_vec(), Object::Ref(m));
}
Stream::from_raw(d, jpeg.clone())
});
let (ext, rgba) = exported_rgba(&doc);
assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[0] <= 6 && p[1] >= 249 && p[2] >= 249), "{what}: {:?}", &rgba[..4]);
if decode.is_some() && !masked {
// Inverted like an image file expects: still written as is.
assert_eq!((ext, &extract_images(&doc, &[0], 0).images[0].data), ("jpg", &jpeg), "{what}");
}
}
}

View File

@ -11,9 +11,7 @@
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_fonts::{
GlyphError, GlyphOutline, ShapedCluster, ShapedScript, helvetica_width, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi,
};
use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi};
use unicode_bidi::{Level, ParagraphBidiInfo};
use crate::{EditError, MarkKind, check, contents, n, page_list, place_tagged};
@ -67,15 +65,30 @@ impl Family {
(family, s.contains("Bold"), s.contains("Italic") || s.contains("Oblique"))
}
/// Approximate advance width of `s` (standard-14 metrics are not bundled: Helvetica widths,
/// scaled for Times; Courier is monospaced).
pub fn width(self, s: &str, size: f64, bold: bool) -> f64 {
let w = match self {
Family::Courier => s.chars().count() as f64 * 0.6 * size,
Family::Times => helvetica_width(s, size) * 0.9,
Family::Helvetica => helvetica_width(s, size),
};
if bold { w * 1.05 } else { w }
/// Advance width of `s` at `size` points, from the published standard-14 metrics.
///
/// `italic` matters: the item is written out as the oblique/italic face (see [`Family::base_font`]),
/// whose published widths differ from the upright face (Times-Italic `A` is 611, Times-Roman 722).
pub fn width(self, s: &str, size: f64, bold: bool, italic: bool) -> f64 {
self.std14(bold, italic).text_width(s, size)
}
/// The standard-14 face this family means at the given style.
pub fn std14(self, bold: bool, italic: bool) -> Std14 {
match (self, bold, italic) {
(Family::Courier, false, false) => Std14::Courier,
(Family::Courier, true, false) => Std14::CourierBold,
(Family::Courier, false, true) => Std14::CourierOblique,
(Family::Courier, true, true) => Std14::CourierBoldOblique,
(Family::Times, false, false) => Std14::TimesRoman,
(Family::Times, true, false) => Std14::TimesBold,
(Family::Times, false, true) => Std14::TimesItalic,
(Family::Times, true, true) => Std14::TimesBoldItalic,
(Family::Helvetica, false, false) => Std14::Helvetica,
(Family::Helvetica, true, false) => Std14::HelveticaBold,
(Family::Helvetica, false, true) => Std14::HelveticaOblique,
(Family::Helvetica, true, true) => Std14::HelveticaBoldOblique,
}
}
}
@ -240,7 +253,7 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
// sum of its words' and shaping stays linear in the paragraph's length. The space is the
// paragraph's; a space between Latin words is drawn in the item's font, a few hundredths
// of an em apart.
let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold) };
let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) };
let space = measure(" ");
let (mut line, mut line_w) = (String::new(), 0.0);
for word in para.split(' ') {
@ -271,7 +284,7 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result<Vec<String>, Edit
Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size))),
Piece::Latin(s) => atoms.extend(s.chars().map(|c| {
let text = c.to_string();
let w = t.family.width(&text, t.size, t.bold);
let w = t.family.width(&text, t.size, t.bold, t.italic);
(text, w)
})),
}
@ -414,7 +427,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
let width = pieces
.iter()
.map(|p| match p {
Piece::Latin(s) => t.family.width(s, t.size, t.bold),
Piece::Latin(s) => t.family.width(s, t.size, t.bold, t.italic),
Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance).sum::<f64>() * t.size,
})
.sum();
@ -424,7 +437,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
/// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing
/// then reports why).
fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold))
shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold, t.italic))
}
fn arabic_error(e: GlyphError, text: &str) -> EditError {
@ -560,7 +573,7 @@ fn draw_laid_shaped(
out.extend(literal(&win_ansi(s)));
out.extend_from_slice(b" Tj\n");
current = Some(latin.as_str());
pen += t.family.width(s, t.size, t.bold) + tw * s.matches(' ').count() as f64;
pen += t.family.width(s, t.size, t.bold, t.italic) + tw * s.matches(' ').count() as f64;
}
Piece::Shaped(clusters) => {
for c in clusters {
@ -713,7 +726,7 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());
for (i, line) in lines(t).iter().enumerate() {
let w = t.family.width(line, t.size, t.bold);
let w = t.family.width(line, t.size, t.bold, t.italic);
let x = match t.align {
Align::Left | Align::Justify => r[0],
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,

View File

@ -8,7 +8,12 @@ use super::*;
/// Regression fingerprint for the small-item fixture: drawing bytes, font map and document
/// objects, with generated streams decoded so Flate backend selection cannot change the hash.
const SMALL_ITEM_FINGERPRINT: u64 = 4_811_174_621_764_261_623;
// Re-pinned when the standard-14 metrics became exact (F1). The Latin run in the fingerprinted
// item is "Hi " in Helvetica 12pt: the old character-class guess called H 680, i 260 and space
// 260 (14.4pt), where the published metrics are 722, 222 and 278 (14.664pt). The Arabic clusters
// that follow therefore start 0.264pt further right, which is the whole point of F1 -- the glyphs
// now land where the font says they do. Nothing about the Arabic path changed.
const SMALL_ITEM_FINGERPRINT: u64 = 9_200_256_185_091_160_371;
/// A cluster that shows one character with the single glyph `id`, at `advance` em.
fn cluster(id: u32, advance: f64) -> ShapedCluster {

View File

@ -168,8 +168,9 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() {
continue;
}
// A subtype this build cannot draw only fails here; it is still removed below. (Text
// the standard fonts can't draw was refused before anything changed: `undrawn_on`.)
// A subtype this build cannot draw fails here and is kept below, so the comment is
// not deleted with nothing drawn in its place. (Text the standard fonts can't draw
// was refused before anything changed: `undrawn_on`.)
let _ = pdfcraft_annot::set_appearance(doc, r);
}
for (k, entry) in list.iter().enumerate() {
@ -185,6 +186,7 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
continue;
}
let rect = nums(doc, d.get(b"Rect")).filter(|r| r.len() == 4).map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]);
let mut drew = false;
if let (Some(ap), Some(rect)) = (appearance(doc, d), rect) {
let form = doc.get(ap).as_dict().cloned().unwrap_or_default();
if let Some(m) = placement(doc, &form, rect) {
@ -192,8 +194,15 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
content.push_str(&format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])));
xobjects.set(name.into_bytes(), Object::Ref(ap));
drawn += 1;
drew = true;
}
}
// A comment whose ink was not written into the page stays. Removing it would drop
// the annotation (a cloudy square this builder cannot draw, for example) with nothing
// left on the page. Widgets are form fields: flattening still removes them.
if !widget && !drew {
continue;
}
match entry.as_ref() {
Some(r) => {
gone.insert(r);
@ -249,33 +258,49 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
})?,
}
}
if !removed_widgets.is_empty() {
prune_fields(doc, &removed_widgets)?;
// A field whose widgets are on no page has nothing to draw, but flattening every field of the
// document must still take it out of the form, or other software keeps seeing one (#1470).
let orphans = matches!(which, Which::Comments { fields: true, .. }) && (0..all.len()).all(|i| pages.contains(&i));
if !removed_widgets.is_empty() || orphans {
let on_page: HashSet<ObjRef> = page_list(doc)
.iter()
.filter_map(|p| p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()))
.flatten()
.filter_map(|e| e.as_ref())
.collect();
prune_fields(doc, &removed_widgets, orphans.then_some(&on_page))?;
}
Ok(drawn)
}
/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets.
fn prune_fields(doc: &mut Document, widgets: &HashSet<ObjRef>) -> Result<(), EditError> {
/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets. With
/// `on_page`, also the widgets (fields without kids) that are on no page.
fn prune_fields(doc: &mut Document, widgets: &HashSet<ObjRef>, on_page: Option<&HashSet<ObjRef>>) -> Result<(), EditError> {
let Some(root) = doc.root() else { return Ok(()) };
let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) };
let af_ref = af.as_ref();
let mut af_dict = doc.resolve(&af).as_dict().cloned().unwrap_or_default();
let fields = af_dict.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
// Returns whether the node survives.
fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet<ObjRef>, depth: usize) -> bool {
fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet<ObjRef>, on_page: Option<&HashSet<ObjRef>>, depth: usize) -> bool {
if widgets.contains(&r) || depth > 64 {
return depth > 64;
}
let Some(d) = doc.get(r).as_dict().cloned() else { return true };
let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else { return true };
let survivors: Vec<Object> = kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, depth + 1))).cloned().collect();
let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else {
return on_page.is_none_or(|p| p.contains(&r));
};
let survivors: Vec<Object> =
kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, on_page, depth + 1))).cloned().collect();
if survivors.len() != kids.len() {
let _ = doc.update_dict(r, |d| d.set(b"Kids".to_vec(), Object::Array(survivors.clone())));
}
!survivors.is_empty()
}
let survivors: Vec<Object> = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, 0))).cloned().collect();
let survivors: Vec<Object> = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, 0))).cloned().collect();
if survivors.len() == fields.len() && widgets.is_empty() {
return Ok(()); // only looked for fields on no page, and there were none
}
af_dict.set(b"Fields".to_vec(), Object::Array(survivors));
match af_ref {
Some(r) => doc.set(r, Object::Dict(af_dict)),

View File

@ -19,7 +19,8 @@ use pdfcraft_fonts::{helvetica_width, literal, win_ansi};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum EditError {
#[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("{0}")]
Invalid(String),

View File

@ -1,6 +1,6 @@
use std::sync::Arc;
use pdfcraft_cos::{Document, SaveOptions, write_incremental};
use pdfcraft_cos::{Document, SaveOptions, write_full, write_incremental};
use super::*;
@ -179,6 +179,7 @@ fn invalid_requests_change_nothing() {
add_background(&mut doc, &[9], &Background { color: [1.0; 3], opacity: 1.0, ..Background::default() }, false),
Err(EditError::NoSuchPage(9))
);
assert_eq!(EditError::NoSuchPage(9).to_string(), "page 10 does not exist", "the message counts pages from 1 (#1550)");
assert!(!doc.is_modified());
}
@ -214,6 +215,37 @@ fn flattening_draws_appearances_into_the_page_and_removes_the_comments() {
assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark");
}
/// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden
/// cloudy square and a cloudy square that already has an appearance keep their previous results.
#[test]
fn flattening_keeps_a_cloudy_square_it_cannot_draw() {
let page = stream("", " ");
let form = "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream";
let case = |extra: &str, drawn: usize, left: bool| {
let annot = format!("<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /Contents (Synthetic comment) {extra} >>");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [4 0 R] >>",
&annot,
&page,
form,
]);
let before = streams(&doc, 0);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), drawn);
let doc = reopen(&doc);
let p = &pdfcraft_model::pages(&doc)[0];
assert_eq!(p.dict.contains(b"Annots"), left);
if drawn == 0 {
assert_eq!(streams(&doc, 0), before, "nothing was drawn in place of the comment");
}
};
case("/BE << /S /C /I 2 >>", 0, true);
case("/BE << /S /S >>", 1, false);
case("/BE << /S /C /I 2 >> /F 2", 0, true);
case("/BE << /S /C /I 2 >> /AP << /N 6 0 R >>", 1, false);
}
/// #805: a supported note with no stored appearance is still drawn by viewers. Flattening must
/// generate that appearance and bake it into the page, not delete a comment that shows an icon.
#[test]
@ -234,6 +266,34 @@ fn flattening_keeps_a_note_whose_appearance_is_missing() {
assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}");
}
/// #1470: a form field whose widget is on no page (left behind by page extraction or combining)
/// has nothing to draw, but flattening every form field still takes it out of `/AcroForm /Fields`,
/// or other software keeps treating the flattened file as a form. A hidden widget on a page is
/// left alone, as before.
#[test]
fn flattening_fields_drops_the_ones_on_no_page() {
let page = stream("", " ");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R] >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 7 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (on_page) /Rect [10 10 150 40] /F 4 /AP << /N 10 0 R >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 50 150 80] /F 4 /AP << /N 10 0 R >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Rect [10 90 150 120] /F 2 /AP << /N 10 0 R >> >>",
"<< /FT /Tx /T (parent) /Kids [9 0 R] >>",
"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 130 150 160] /F 4 /AP << /N 10 0 R >> >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream",
]);
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field on the page is drawn");
let doc = reopen(&doc);
let root = doc.root().unwrap();
let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap());
let fields = doc.resolve(af.as_dict().unwrap().get(b"Fields").unwrap());
let left: Vec<pdfcraft_cos::ObjRef> = fields.as_array().unwrap().iter().filter_map(|f| f.as_ref()).collect();
assert_eq!(left, [pdfcraft_cos::ObjRef::new(7, 0)], "only the hidden field on the page stays");
}
/// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an
/// appearance whose text Helvetica can't draw (another program's), and a comment or form field
/// whose appearance PdfCraft drew with `?` (marked `PCUndrawn`). A comment another program drew
@ -1824,6 +1884,52 @@ fn a_line_drawn_twice_is_replaced_everywhere() {
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hello there", "world"]);
}
#[test]
fn replacing_a_line_keeps_a_distinct_nearby_line() {
// 12 pt lines 6 pt apart: the glyph boxes overlap by more than half the smaller box,
// but the baselines are different lines (#1316).
let src = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -6 Td (Beta) Tj ET";
let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::<Vec<_>>();
for paragraph in [false, true] {
let mut doc = text_page(src);
assert_eq!(listed(&doc), ["Alpha", "Beta"]);
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2);
if paragraph {
text::rewrite_block(&mut doc, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, "ALPHA").unwrap();
}
assert_eq!(listed(&doc), ["ALPHA", "Beta"]);
assert!(String::from_utf8_lossy(&page_content_bytes(&doc, 0)).contains("(Beta)"));
for bytes in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] {
let back = Document::open(Arc::new(bytes)).unwrap();
assert!(back.repair_log().is_empty());
assert_eq!(listed(&back), ["ALPHA", "Beta"]);
}
}
// The same word on the next line is still a different line.
let mut same = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -6 Td (Alpha) Tj ET");
text::replace_line(&mut same, 0, 0, "ALPHA").unwrap();
assert_eq!(listed(&same), ["ALPHA", "Alpha"]);
// Ordinary leading, and a paragraph far enough to group apart, keep the neighbour.
let mut spaced = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -14.4 Td (Beta) Tj ET");
text::replace_line(&mut spaced, 0, 0, "ALPHA").unwrap();
assert_eq!(listed(&spaced), ["ALPHA", "Beta"]);
let mut apart = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -40 Td (Beta) Tj ET");
text::rewrite_block(&mut apart, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap();
assert_eq!(listed(&apart), ["ALPHA", "Beta"]);
// A second copy at the same position is still one replacement.
for paragraph in [false, true] {
let mut duplicate = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj ET BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj ET");
if paragraph {
text::rewrite_block(&mut duplicate, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap();
} else {
text::replace_line(&mut duplicate, 0, 0, "ALPHA").unwrap();
}
assert_eq!(listed(&duplicate), ["ALPHA"]);
}
}
#[test]
fn paragraphs_move_and_rewrap_to_a_new_width() {
let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET";
@ -2755,14 +2861,17 @@ fn group_move_budget_refusals_keep_the_original_page_selectable() {
#[test]
fn arabic_actual_text_keeps_typing_order_when_opened_for_editing() {
for logical in ["مرحبا بالعالم", "سَلَام 123 (PDF)", "English مرحبا ١٢٣", "שלום עולם", "Latin text"] {
for logical in ["مرحبا بالعالم", "سَلَام 123 (PDF)", "English مرحبا ١٢٣", "PDFمرحبا", "مرحبا", "שלום עולם", "Latin text"]
{
let hex: String = logical.encode_utf16().map(|u| format!("{u:04X}")).collect();
let doc = text_page(&format!("/Span <</ActualText <FEFF{hex}>>> BDC BT /F1 12 Tf 72 700 Td (abcd) Tj (efgh) Tj ET EMC"));
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, logical, "ActualText is already in typing order");
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, logical);
for drawing in ["(abcd) Tj (efgh) Tj", "(X) Tj"] {
let doc = text_page(&format!("/Span <</ActualText <FEFF{hex}>>> BDC BT /F1 12 Tf 72 700 Td {drawing} ET EMC"));
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, logical, "ActualText is already in typing order");
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, logical);
}
}
}
}
@ -2789,6 +2898,78 @@ fn actual_text_and_visual_rtl_glyphs_keep_their_own_reading_order() {
}
}
/// Synthetic codes A س, B م and X a lam-alef glyph, drawn from left to right.
fn arabic_ligature_page(content: &str, composite: bool) -> Document {
let prefix = if composite { "00" } else { "" };
let cmap = format!(
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \
1 begincodespacerange <{prefix}00> <{prefix}FF> endcodespacerange 4 beginbfchar \
<{prefix}20> <0020> <{prefix}41> <0633> <{prefix}42> <0645> <{prefix}58> <FEFB> \
endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"
);
let widths: Vec<_> = (32..=88).map(|c| if c == 32 { "250" } else { "500" }).collect();
let font = if composite {
"<< /Type /Font /Subtype /Type0 /BaseFont /FakeArabic /Encoding /Identity-H /DescendantFonts [7 0 R] /ToUnicode 6 0 R >>".into()
} else {
format!("<< /Type /Font /Subtype /Type1 /BaseFont /FakeArabic /FirstChar 32 /LastChar 88 /Widths [{}] /ToUnicode 6 0 R >>", widths.join(" "))
};
build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
&stream("", content),
&font,
&stream("", &cmap),
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /FakeArabic /CIDSystemInfo << /Registry (Synthetic) /Ordering (Identity) /Supplement 0 >> /DW 500 /W [32 [250]] >>",
])
}
#[test]
fn arabic_ligature_actual_text_stays_on_its_line() {
for (ligature, composite) in [("لا", false), ("لأ", false), ("لإ", false), ("لآ", false), ("لَا", false), ("لا", true)] {
let hex: String = ligature.encode_utf16().map(|u| format!("{u:04X}")).collect();
let (left, glyph, right) = if composite { ("<0042>", "<0058>", "<0041>") } else { ("(B)", "(X)", "(A)") };
let doc = arabic_ligature_page(
&format!("BT /F1 12 Tf 72 700 Td {left} Tj /Span <</ActualText <FEFF{hex}>>> BDC [{glyph}] TJ EMC {right} Tj ET"),
composite,
);
let expected = format!("س{ligature}م");
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), [expected.as_str()]);
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, expected);
}
}
}
#[test]
fn multi_glyph_rtl_actual_text_keeps_its_own_reading_order() {
// Two encoded glyphs are a logical span, even if their replacement looks like a ligature.
for drawing in ["(XX) Tj", "(X) Tj (X) Tj", "[(X) (X)] TJ"] {
let doc =
arabic_ligature_page(&format!("BT /F1 12 Tf 72 700 Td (B) Tj /Span <</ActualText <FEFF06440627>>> BDC {drawing} EMC (A) Tj ET"), false);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["م", "لا", "س"]);
}
}
#[test]
fn replacing_word_with_actual_text_ligature_removes_all_its_source_operators() {
let mut doc = arabic_ligature_page(
"BT /F1 12 Tf 72 700 Td (B) Tj /Span <</ActualText <FEFF06440627>>> BDC /Span BMC (X) Tj EMC EMC (A) Tj ET \
BT /F1 12 Tf 72 600 Td (AB) Tj ET",
false,
);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, "سلام");
text::replace_block(&mut doc, 0, 0, "Replacement").unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Replacement", "مس"]);
let bytes = page_content_bytes(&doc, 0);
let content = String::from_utf8_lossy(&bytes);
assert!(!content.contains("ActualText") && !content.contains("(X)") && !content.contains("(B)") && !content.contains("(A)"), "{content}");
}
#[test]
fn replacing_arabic_actual_text_removes_the_whole_original_drawing() {
let logical = "مرحبا بالعالم";
@ -3238,3 +3419,27 @@ fn subset_indic_font_without_legacy_name_keeps_logical_text() {
assert!(!lines[0].visual_order);
assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}");
}
/// An added item is written out as a specific standard-14 face (`/Times-Italic`, `/Helvetica-Bold`
/// …), so it has to be *measured* with that same face. Measuring the upright face while writing
/// the italic one mis-sizes every box: Times-Italic `A` is 611 against Times-Roman's 722.
#[test]
fn an_items_measured_face_is_the_face_it_is_written_as() {
for family in [Family::Helvetica, Family::Times, Family::Courier] {
for bold in [false, true] {
for italic in [false, true] {
let written = family.base_font(bold, italic);
let measured = family.std14(bold, italic);
assert_eq!(written, measured.base_font(), "{family:?} bold={bold} italic={italic}");
// ... and the public measure reaches that face, rather than an upright stand-in.
assert_eq!(family.width("Anna", 12.0, bold, italic), measured.text_width("Anna", 12.0), "{family:?} bold={bold} italic={italic}");
}
}
}
// The faces really do differ, so the assertions above are not all comparing the same number.
let times = |italic| Family::Times.width("Anna", 12.0, false, italic);
assert_ne!(times(false), times(true), "Times-Italic is not Times-Roman");
// Courier is monospaced in every style: equal widths here are correct, not a collapsed face.
let courier = |italic| Family::Courier.width("Anna", 12.0, false, italic);
assert_eq!(courier(false), courier(true));
}

View File

@ -458,6 +458,8 @@ fn fill_color(fill: &[Op]) -> [f64; 3] {
struct Shown {
op: usize,
extra_ops: Vec<usize>,
/// Encoded glyphs, not bytes or Unicode characters (a ligature can represent several).
glyph_count: usize,
/// Advance in text space, including spacing and TJ adjustments.
advance: f64,
/// The text matrix where it starts (text space), and the state there and at its `BT`.
@ -553,6 +555,7 @@ fn interpret(
let mut items = tail.into_iter();
if let Some(mut first) = items.next() {
for shown in items {
first.glyph_count = first.glyph_count.saturating_add(shown.glyph_count);
first.extra_ops.push(shown.op);
first.extra_ops.extend(shown.extra_ops);
first.rect = [
@ -661,10 +664,12 @@ fn interpret(
let mut text = String::new();
let mut decodable = true;
let mut x_text = 0.0;
let mut glyph_count = 0_usize;
for p in &pieces {
match p {
Object::String(s) => {
for (code, len) in m.codes(&s.bytes) {
glyph_count = glyph_count.saturating_add(1);
match m.text_of(code) {
Some(t) => text.push_str(&t),
None => decodable = false,
@ -706,6 +711,7 @@ fn interpret(
out.push(Shown {
op: i,
extra_ops: Vec::new(),
glyph_count,
advance: x_text,
tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]),
state: ts.clone(),
@ -754,7 +760,15 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, true);
let mut last: Option<(usize, usize, f64, f64, f64)> = None; // (piece, bt, baseline, end_x, size)
for s in shown {
for mut s in shown {
// A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a
// separate logical line. Put its letters into visual order before joining the
// surrounding glyphs; the completed line is recovered to typing order below.
// Multi-glyph and mixed-direction replacements keep their authoritative order.
if s.logical_order && s.glyph_count == 1 && s.text.chars().all(crate::bidi::is_rtl) {
s.text = crate::bidi::to_visual(&s.text);
s.logical_order = false;
}
// A line belongs to one stream piece. Within it, PDF producers often put every word
// in its own BT/ET: geometry and text state, not object boundaries, define a line.
let si = ops.get(s.op).map_or(0, |op| pieces.pieces_of(op).0);
@ -1471,8 +1485,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
}
}
// Rebuild: the line's first operator becomes the replacement, its others go. Copies of the
// line drawn in the same area go too, so the replacement is all that shows.
let drops = coincident_ops(&lines, std::slice::from_ref(&target.rect));
// line drawn on the same baseline go too, so the replacement is all that shows.
let drops = coincident_ops(&lines, &[&target]);
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
let contents = rewritten_contents(doc, &streams, new_data);
let page_ref = p.obj;
@ -1493,27 +1507,44 @@ fn font_size_before(ops: &[Op], at: usize) -> Option<f64> {
ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1))
}
/// The operators of every line drawn in the same area as one of `rects`. Documents sometimes
/// draw a line more than once (fake bold, an invisible text layer), and a surviving copy would
/// show the old text under the replaced line. Copies overlap a member's rect by more than half of
/// the smaller rect; neighbouring lines share no area.
fn coincident_ops(lines: &[TextLine], rects: &[[f64; 4]]) -> std::collections::HashSet<usize> {
/// Operators of every line that is another drawing of one of `targets` (fake bold, an invisible
/// text layer). A surviving copy would show the old text under the replacement.
///
/// A copy overlaps a target by more than half the smaller box and sits on the same baseline
/// (within the tolerance that joins showing operators onto one line). A neighbouring line can
/// share box area when the leading is tighter than the font's ascent plus descent; its baseline
/// is farther away, so it stays.
fn coincident_ops(lines: &[TextLine], targets: &[&TextLine]) -> std::collections::HashSet<usize> {
let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0);
let mut drop = std::collections::HashSet::new();
for l in lines {
let covered = rects.iter().any(|m| {
let ix = (m[2].min(l.rect[2]) - m[0].max(l.rect[0])).max(0.0);
let iy = (m[3].min(l.rect[3]) - m[1].max(l.rect[1])).max(0.0);
let small = area(*m).min(area(l.rect));
small > 0.0 && ix * iy / small > 0.5
});
if covered {
drop.extend(l.ops.iter().copied());
for line in lines {
let copy = targets.iter().any(|target| overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target));
if copy {
drop.extend(line.ops.iter().copied());
}
}
drop
}
fn overlaps_half(a: [f64; 4], b: [f64; 4], area: impl Fn([f64; 4]) -> f64) -> bool {
let ix = (b[2].min(a[2]) - b[0].max(a[0])).max(0.0);
let iy = (b[3].min(a[3]) - b[1].max(a[1])).max(0.0);
let small = area(b).min(area(a));
small > 0.0 && ix * iy / small > 0.5
}
/// Perpendicular distance between baselines, in user space. Horizontal text compares height;
/// turned text compares the distance across the writing direction.
fn on_same_baseline(line: &TextLine, target: &TextLine) -> bool {
let angle = target.rotation().to_radians();
let (sin, cos) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) };
let dx = line.origin.x - target.origin.x;
let dy = line.origin.baseline - target.origin.baseline;
let offset = dx.mul_add(-sin, dy * cos).abs();
let size = line.size.min(target.size).max(1.0);
offset.is_finite() && offset < size * 0.3
}
/// The paragraphs on a page (0-based).
pub fn text_blocks(doc: &Document, page: usize) -> Result<Vec<TextBlock>, EditError> {
let lines = text_lines(doc, page)?;
@ -1527,8 +1558,8 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
let lines = text_lines(doc, page)?;
let blocks = group_blocks(&lines);
let target = blocks.get(block).ok_or_else(|| EditError::Invalid("the paragraph no longer exists".into()))?;
let rects: Vec<_> = target.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).collect();
let mut drops = coincident_ops(&lines, &rects);
let targets: Vec<&TextLine> = target.lines.iter().filter_map(|i| lines.get(*i)).collect();
let mut drops = coincident_ops(&lines, &targets);
drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied()));
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
@ -1724,13 +1755,9 @@ pub(crate) fn rewrite_block_with_limit(
// The chosen style also determines the real Japanese fallback outlines and advances.
let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic));
let bold = style.bold.unwrap_or(bold);
let std_width = move |s: &str, size: f64| -> f64 {
match family {
crate::added::Family::Courier => s.chars().count() as f64 * 0.6 * size,
crate::added::Family::Times => pdfcraft_fonts::helvetica_width(s, size) * 0.92,
crate::added::Family::Helvetica => pdfcraft_fonts::helvetica_width(s, size) * if bold { 1.05 } else { 1.0 },
}
};
// The face is chosen after `bold` settles, so a bold request measures with the bold metrics.
let face = family.std14(bold, italic);
let std_width = move |s: &str, size: f64| -> f64 { face.text_width(s, size) };
let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]);
if !(dx.is_finite() && dy.is_finite()) {
return Err(EditError::Invalid("the paragraph can't be moved that far".into()));
@ -1921,10 +1948,9 @@ pub(crate) fn rewrite_block_with_limit(
block_ops.push(Op::new("Q", vec![]));
}
block_ops.push(Op::new("Q", vec![]));
// The paragraph's operators go, and so does anything drawn in the same area (a fake-bold
// The paragraph's operators go, and so does another drawing on the same baseline (a fake-bold
// second copy, an invisible text layer): the new text is all that may show.
let member_rects: Vec<[f64; 4]> = members.iter().map(|l| l.rect).collect();
let mut drop = coincident_ops(&lines, &member_rects);
let mut drop = coincident_ops(&lines, &members);
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
// Text shown earlier in the same text object stays first: the paragraph then goes where it
// was, between two halves of the text object, so it keeps its place in reading order.

View File

@ -696,8 +696,7 @@ pub(super) fn rewrite_styled_block(
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let members: Vec<_> = block.lines.iter().filter_map(|i| lines.get(*i)).collect();
let rects: Vec<_> = members.iter().map(|l| l.rect).collect();
let mut drop = coincident_ops(lines, &rects);
let mut drop = coincident_ops(lines, &members);
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
let o = &first.origin;
let start = first.ops.first().copied().unwrap_or(o.bt_op);

View File

@ -0,0 +1,35 @@
//! What this build calls itself. A local or patched build sets `PDFCRAFT_BUILD_LABEL` at compile
//! time (`PDFCRAFT_BUILD_LABEL=titlebar cargo build`, or through `packaging/linux/package.sh`), so
//! it can be told from the release of the same version. The version itself stays the release's:
//! the update check compares it with published releases.
/// The release version from the workspace `Cargo.toml`.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// The compile-time build label, if any.
pub const LABEL: Option<&str> = option_env!("PDFCRAFT_BUILD_LABEL");
/// The version as shown to people: `0.7.1`, or `0.7.1 (titlebar)` for a labelled build.
pub fn version() -> String {
describe(VERSION, LABEL)
}
fn describe(version: &str, label: Option<&str>) -> String {
match label.map(str::trim).filter(|l| !l.is_empty()) {
Some(label) => format!("{version} ({label})"),
None => version.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::describe;
#[test]
fn a_label_follows_the_version_and_a_blank_one_is_ignored() {
assert_eq!(describe("0.7.1", None), "0.7.1");
assert_eq!(describe("0.7.1", Some("")), "0.7.1");
assert_eq!(describe("0.7.1", Some(" ")), "0.7.1");
assert_eq!(describe("0.7.1", Some("titlebar")), "0.7.1 (titlebar)");
}
}

View File

@ -457,7 +457,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
sections: &[ToolSection {
title: "Accessibility",
items: &[
item("Automatically tag PDF", "tag", "a11y.autotag", Planned("M12")),
item("Automatically tag PDF", "tag", "a11y.autotag", Ready),
item("Change reading options", "book-open", "a11y.reading_options", Ready),
item("Check for accessibility", "accessibility", "a11y.check", Ready),
item("Open accessibility report", "file-text", "a11y.report", Ready),

View File

@ -216,6 +216,11 @@ pub const COMMANDS: &[CommandSpec] = &[
c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"),
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"),
// Browsers' tab keys (#1542): Ctrl+Tab on every platform, ⌃Tab rather than ⌘Tab on macOS,
// where the system takes ⌘Tab. They follow the tabs' order on screen and wrap around, and
// also run from Combine files, which has no document open.
c("view.next_tab", "Next tab", VIEW, Some(Shortcut { command: false, shift: false, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-right"),
c("view.previous_tab", "Previous tab", VIEW, Some(Shortcut { command: false, shift: true, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-left"),
c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"),
c("ui.dock.reset", "Reset panel layout", VIEW, None, Nothing, "settings"),
c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"),
@ -321,6 +326,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"),
c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"),
c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"),
c("a11y.autotag", "Automatically tag PDF", None, None, Document, "tag"),
c("a11y.report", "Open accessibility report", None, None, Document, "file-text"),
c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"),
c("a11y.alt_text", "Add alternate text…", None, None, Modification, "image"),

View File

@ -14,6 +14,7 @@
pub mod actions;
pub mod autotag;
pub mod build_info;
pub mod camera;
pub mod camera_clean;
pub mod camera_edges;
@ -113,7 +114,9 @@ pub fn typed_signature_graphic(text: &str) -> Option<pdfcraft_sign::Graphic> {
o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours))
}
pub use links::{MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, resolve_remote_document_path};
pub use links::{
MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
};
/// Comment geometry helpers (text-box line breaking) for frontends.
pub use pdfcraft_annot::appearance as annot_text;
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
@ -772,6 +775,74 @@ fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u
write_incremental(&display, &SaveOptions::default()).map(Arc::new).unwrap_or_else(|_| bytes.clone())
}
/// Fails when the renderer still draws text under an applied redaction mark.
///
/// Redaction decides what lies under a mark from the positions its own interpreter computes, and
/// its verify pass reuses them. Where they differ from the renderer's (a standard font without
/// /Widths, whose advances the interpreter approximates; a CMap, or a font set outside `Tf`,
/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the
/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is
/// what the user saw when placing the marks, so it is asked. `after` is written as a save would
/// write it, without the marks' own overlay (the box and its label are drawn on purpose), its
/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps
/// the document as it was.
fn shown_under_redactions(
after: &pdfcraft_cos::Document,
marks: &[pdfcraft_redact::Mark],
pages: &[pdfcraft_render::PageInfo],
password: Option<&str>,
) -> Result<(), EditError> {
use pdfcraft_cos::Object;
use pdfcraft_redact::RedactError;
let mut marked: Vec<usize> = marks.iter().map(|m| m.page).collect();
marked.sort_unstable();
marked.dedup();
if marked.is_empty() {
return Ok(());
}
let mut shown = after.clone();
let model = pdfcraft_model::pages(&shown);
for &pi in &marked {
let Some(page) = model.get(pi) else { continue };
let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue };
let kept: Vec<Object> =
list.into_iter().filter(|o| shown.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")).collect();
shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?;
}
let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?;
let config = RenderConfig { password: password.map(Arc::from), ..Default::default() };
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config);
let mut residue = 0;
for &pi in &marked {
let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() };
// A page that cannot be read back cannot be confirmed clean.
let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) };
let rects: Vec<[f64; 4]> = marks
.iter()
.filter(|m| m.page == pi)
.flat_map(|m| m.rects.iter())
.map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])])
.collect();
for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) {
let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from);
let area = (x1 - x0) * (y1 - y0);
if area.is_nan() || area <= 0.0 {
continue;
}
// Redaction itself takes any glyph a fifth under a mark; one that is half under it
// and still drawn was not where redaction thought it was.
let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area;
if rects.iter().any(under) {
residue += 1;
}
}
}
if residue > 0 {
return Err(RedactError::Residue(residue).into());
}
Ok(())
}
fn render_threads() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1
}
@ -1259,6 +1330,8 @@ pub enum Edit {
},
/// Document Properties ▸ Initial View (and the language and binding).
SetInitialView(Box<InitialView>),
/// Build a tagged structure tree for an untagged, text-only document.
AutoTagText,
/// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is
/// the figure element's object number (from `Document::figures`).
SetAltText {
@ -1740,6 +1813,7 @@ impl Edit {
Edit::DuplicateField { .. } => Label::of("Duplicate field"),
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"),
Edit::SetInitialView(_) => Label::of("Change initial view"),
Edit::AutoTagText => Label::of("Automatically tag text"),
Edit::SetAltText { .. } => Label::of("Set alternate text"),
Edit::SetTagProperty { .. } => Label::of("Change tag properties"),
Edit::MoveTag { .. } => Label::of("Move object"),
@ -1928,6 +2002,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
| Edit::SetTabOrder { .. }
| Edit::MoveInTabOrder { .. }
| Edit::SetInitialView(_)
| Edit::AutoTagText
| Edit::SetAltText { .. }
| Edit::SetTagProperty { .. }
| Edit::MoveTag { .. }
@ -2092,7 +2167,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
let src = mark_source(doc, file)?;
let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0));
let natural = rect[2] == rect[0] && rect[3] == rect[1];
let rotation = if natural && src.image { pdfcraft_organize::page_rotation(doc, *page)? } else { 0 };
let rotation = pdfcraft_organize::page_rotation(doc, *page)?;
let rect = if natural {
let k = (200.0 / sw.max(sh)).min(1.0);
let (w, h) = if matches!(rotation, 90 | 270) { (sh * k, sw * k) } else { (sw * k, sh * k) };
@ -2202,6 +2277,9 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?,
Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?,
Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?,
Edit::AutoTagText => {
a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::SetAltText { figure, alt } => {
let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure));
a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?;
@ -2609,6 +2687,34 @@ pub struct Session {
date_language: Option<String>,
}
/// Immutable session state needed while a document is prepared away from its owning thread.
#[derive(Clone)]
pub struct OpenContext {
now: Option<i64>,
js_off: bool,
trust: Arc<TrustStore>,
}
/// A fully prepared document that has not yet been registered in a session.
pub struct PreparedDocument {
document: Document,
}
impl OpenContext {
/// Prepare a document without mutating the session that supplied this context.
pub fn prepare_open(
&self,
name: impl Into<String>,
path: Option<String>,
bytes: Arc<Vec<u8>>,
password: Option<&str>,
) -> Result<PreparedDocument, OpenError> {
let name = name.into();
guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password))
.unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})"))))
}
}
/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances.
fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result<XfaLayout, String> {
let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?;
@ -2901,13 +3007,45 @@ impl Session {
self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string())
}
pub fn open(&mut self, name: impl Into<String>, path: Option<String>, bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocId, OpenError> {
let name = name.into();
guard(|| self.open_unguarded(name, path, bytes, password))
.unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})"))))
/// Snapshot the session state that affects open-time scripts, dates and signature trust.
pub fn open_context(&self) -> OpenContext {
OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone() }
}
fn open_unguarded(&mut self, name: String, path: Option<String>, bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocId, OpenError> {
/// Prepare a document without adding it to this session.
pub fn prepare_open(
&self,
name: impl Into<String>,
path: Option<String>,
bytes: Arc<Vec<u8>>,
password: Option<&str>,
) -> Result<PreparedDocument, OpenError> {
self.open_context().prepare_open(name, path, bytes, password)
}
/// Register a prepared document. This is the only open step that mutates the session.
pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId {
self.next_id += 1;
let id = DocId(self.next_id);
let mut document = prepared.document;
document.id = id;
self.docs.push(document);
id
}
/// Synchronous convenience API used by the CLI and automation.
pub fn open(&mut self, name: impl Into<String>, path: Option<String>, bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocId, OpenError> {
let prepared = self.prepare_open(name, path, bytes, password)?;
Ok(self.commit_open(prepared))
}
fn prepare_open_unguarded(
context: &OpenContext,
name: String,
path: Option<String>,
bytes: Arc<Vec<u8>>,
password: Option<&str>,
) -> Result<PreparedDocument, OpenError> {
let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password));
// The renderer authenticates on its own. It cannot use the owner password of R2–R4
// files, so give it the user password that owner authentication recovers.
@ -2926,7 +3064,7 @@ impl Session {
// A dynamic XFA form is a shell around an XML template; lay the template out into real
// pages and fields so the rest of the engine works on it. The original bytes stay: the
// laid-out form is one appended revision.
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
// Write `work` as one more revision and reopen it: what the document then is.
let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc<Vec<u8>>, DocInfo, pdfcraft_cos::Document), String> {
let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?;
@ -2995,7 +3133,7 @@ impl Session {
_ => None,
};
let (bytes, info, cos) = match (&xfa, cos) {
(Some(_), Ok(Ok(cos))) if !self.js_off => match xfa_template.clone() {
(Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() {
Some(tpl) => {
let mut work = cos.clone();
let pages = info.pages.len();
@ -3038,24 +3176,22 @@ impl Session {
};
// XFA notes survive the document being re-read after edits.
let xfa_warnings: Vec<String> = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() };
let id = self.push_document(name, path, bytes, info, cos, render_password, password, xfa)?;
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
// A script that ran away on open turns the form's scripts off for this document.
d.xfa_template = if ran_away { None } else { xfa_template };
d.js_output.append(script_output);
if ran_away {
d.xfa_scripts_off = true;
d.js_output.errors.push(XFA_SCRIPTS_OFF.into());
}
note_warnings(&mut d.xfa_warnings, &xfa_warnings);
let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa);
// A script that ran away on open turns the form's scripts off for this document.
document.xfa_template = if ran_away { None } else { xfa_template };
document.js_output.append(script_output);
if ran_away {
document.xfa_scripts_off = true;
document.js_output.errors.push(XFA_SCRIPTS_OFF.into());
}
Ok(id)
note_warnings(&mut document.xfa_warnings, &xfa_warnings);
Ok(PreparedDocument { document })
}
/// The last step of opening: build the document record and register it.
#[allow(clippy::too_many_arguments)]
fn push_document(
&mut self,
fn build_document(
trust: &Arc<TrustStore>,
name: String,
path: Option<String>,
bytes: Arc<Vec<u8>>,
@ -3064,7 +3200,7 @@ impl Session {
render_password: Option<String>,
password: Option<&str>,
xfa: Option<XfaLayout>,
) -> Result<DocId, OpenError> {
) -> Document {
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
let (mut editor, read_only_reason) = match cos {
Ok(Ok(cos)) => {
@ -3114,11 +3250,9 @@ impl Session {
.unwrap_or_default();
let links = editor.as_ref().map(|e| pdfcraft_annot::links::list(&e.cos)).unwrap_or_default();
let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default());
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, &self.trust, &sig_cache)).unwrap_or_default();
self.next_id += 1;
let id = DocId(self.next_id);
self.docs.push(Document {
id,
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default();
Document {
id: DocId(0),
name,
path,
bytes,
@ -3135,7 +3269,7 @@ impl Session {
added,
links,
signatures,
trust: self.trust.clone(),
trust: trust.clone(),
sig_cache,
editor,
config,
@ -3146,8 +3280,7 @@ impl Session {
fonts_stale: false,
xfa_warnings: Vec::new(),
edit_notices: Vec::new(),
});
Ok(id)
}
}
fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> {
@ -3204,6 +3337,14 @@ impl Session {
// `next` is a copy: if the edit fails or crashes, the document is unchanged.
guard(|| run_edit(&mut next, &edit, &mut cx))
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
// Redaction removed what its own interpreter places under the marks. The marks were drawn
// over the page as the renderer shows it, so the renderer has the last word.
if let Edit::ApplyRedactions { pages } = &edit {
let marks: Vec<pdfcraft_redact::Mark> =
pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect();
guard(|| shown_under_redactions(&next, &marks, &doc.info.pages, doc.password.as_deref()))
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
}
// Renamed fields are said, not slipped in: a repair-log note (with the caveat that
// scripts were left alone) and a count in the undo step's label.
let renamed_note = |renamed: &[(String, String)]| {

View File

@ -165,6 +165,13 @@ impl std::fmt::Display for RemoteDocumentPathError {
}
}
/// Whether the original GoToR file specification is absolute on this platform.
/// Check this before resolving relative paths against the source document. This does no I/O;
/// the resolver must still reject URL/network paths and malformed file specifications.
pub fn remote_document_path_is_absolute(raw: &str) -> bool {
Path::new(raw).is_absolute()
}
/// Resolve a PDF remote-Go-To file specification to a local path without opening it.
/// Relative paths are based on the source document; an in-memory source has no fallback to CWD.
pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<PathBuf, RemoteDocumentPathError> {
@ -181,7 +188,7 @@ pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<
return Err(RemoteDocumentPathError::UrlOrNetwork);
}
let path = Path::new(raw);
let drive_absolute = path.is_absolute();
let drive_absolute = remote_document_path_is_absolute(raw);
if let Some((scheme, _)) = raw.split_once(':') {
let valid_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
@ -450,6 +457,13 @@ mod tests {
assert_eq!(read(&dir.join("folder.pdf")), Err(E::NotAFile), "a directory is refused");
assert_eq!(read(&dir.join("notes.txt")), Err(E::NotPdf));
assert_eq!(read(&dir), Err(E::NotPdf));
#[cfg(unix)]
{
// Sparse: the size guard must reject this without allocating or reading a GiB.
let large = dir.join("large.pdf");
std::fs::File::create(&large).unwrap().set_len(super::MAX_REMOTE_DOCUMENT_BYTES + 1).unwrap();
assert_eq!(read(&large), Err(E::TooLarge));
}
assert!(matches!(read(&dir.join("missing.pdf")), Err(E::Read(_))));
#[cfg(unix)]
{
@ -464,6 +478,26 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn remote_document_absolute_classification_uses_the_original_platform_path() {
use super::remote_document_path_is_absolute as absolute;
for relative in ["target.pdf", "../appendix/target.pdf", "folder/target.pdf"] {
assert!(!absolute(relative), "{relative}");
}
#[cfg(unix)]
{
assert!(absolute("/manual/target.pdf"));
assert!(!absolute(r"C:\manual\target.pdf"));
}
#[cfg(windows)]
{
assert!(absolute(r"C:\manual\target.pdf"));
assert!(absolute("C:/manual/target.pdf"));
assert!(!absolute(r"C:target.pdf"), "drive-relative is not absolute");
assert!(!absolute(r"\manual\target.pdf"), "rooted without a drive is not absolute");
}
}
#[test]
fn remote_document_paths_are_local_and_source_relative() {
use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};

View File

@ -36,6 +36,20 @@ fn session_with(n: usize) -> (Session, DocId) {
(s, id)
}
#[test]
fn prepared_open_does_not_mutate_the_session_until_commit() {
fn assert_send<T: Send>() {}
assert_send::<crate::PreparedDocument>();
let mut session = Session::new();
let prepared = session.prepare_open("prepared.pdf", None, Arc::new(fixture(3)), None).expect("prepares");
assert!(session.get(DocId(1)).is_none());
let id = session.commit_open(prepared);
assert_eq!(id, DocId(1));
assert_eq!(session.get(id).expect("committed document").info.pages.len(), 3);
}
#[test]
fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() {
let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]];
@ -100,6 +114,191 @@ fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() {
}
}
/// A blank page turned `degrees`, with one annotation placed from displayed coordinates.
fn rotated_blank(degrees: i64) -> (Session, DocId) {
let mut session = Session::new().with_clock(|| 1_700_000_000);
let blank = session.create_blank(200.0, 300.0, 1).unwrap();
let id = session.open_new("drawing.pdf", blank).unwrap();
session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap();
(session, id)
}
/// The user-space box of a `w` by `h` point rectangle centred at a displayed point.
fn displayed_rect(info: &pdfcraft_render::PageInfo, vx: f64, vy: f64, w: f64, h: f64) -> [f64; 4] {
let corners = [(vx - w / 2.0, vy - h / 2.0), (vx + w / 2.0, vy + h / 2.0)];
let mapped: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.view_to_user(*x as f32, *y as f32)).collect();
[
f64::from(mapped[0][0].min(mapped[1][0])),
f64::from(mapped[0][1].min(mapped[1][1])),
f64::from(mapped[0][0].max(mapped[1][0])),
f64::from(mapped[0][1].max(mapped[1][1])),
]
}
fn render_page(session: &Session, id: DocId) -> pdfcraft_render::RenderedPage {
let doc = session.get(id).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 2.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
rendered
}
fn stamp_pixel(rendered: &pdfcraft_render::RenderedPage, x: u32, y: u32) -> [u8; 4] {
let offset = ((y * rendered.width + x) * 4) as usize;
rendered.rgba[offset..offset + 4].try_into().unwrap()
}
/// #1398: a Sign Here tag points left as displayed, on every page rotation, including after save.
#[test]
fn rubber_stamps_point_left_as_displayed_on_rotated_pages() {
let (w, h) = StampKind::SignHere.size();
for degrees in [0, 90, 180, 270] {
let (mut session, id) = rotated_blank(degrees);
let info = session.get(id).unwrap().info.pages[0].clone();
let rect = displayed_rect(&info, 100.0, 40.0, w, h);
let shape = Shape::Stamp { rect, stamp: StampKind::SignHere, by: None };
session
.apply(
id,
Edit::AddAnnotation(NewAnnotation {
page: 0,
style: Style::default_for(&shape),
shape,
contents: String::new(),
author: "Test".into(),
}),
)
.unwrap();
let check = |session: &Session, id: DocId| {
let rendered = render_page(session, id);
let doc = session.get(id).unwrap();
let info = doc.info.pages[0].clone();
let stamp = doc.info.annotations[0].rect;
let a = info.user_to_view(stamp[0], stamp[1]);
let b = info.user_to_view(stamp[2], stamp[3]);
let (left, top) = (a[0].min(b[0]), a[1].min(b[1]));
let (right, bottom) = (a[0].max(b[0]), a[1].max(b[1]));
let scale = 2.0;
let at = |x: f32, y: f32| stamp_pixel(&rendered, (x * scale) as u32, (y * scale) as u32);
// The tag's point is the left edge. The flat end fills the right edge.
let tip = at(left + 4.0, (top + bottom) / 2.0);
let outside = at(left + 4.0, top + 4.0);
let end = at(right - 4.0, top + 4.0);
assert!(tip[0] > 180 && tip[2] < 80, "rotation {degrees}: tip {tip:?} should be the orange point");
assert!(outside.iter().all(|c| *c > 240), "rotation {degrees}: above the point {outside:?} should be paper");
assert!(end[0] > 180 && end[2] < 80, "rotation {degrees}: flat end {end:?} should be orange");
};
check(&session, id);
let saved = session.save_bytes(id).unwrap();
let mut reopened = Session::new().with_clock(|| 1_700_000_000);
let again = reopened.open("drawing.pdf", None, saved, None).unwrap();
check(&reopened, again);
}
}
/// A one-page PDF whose left half is red and whose right half is white, as displayed.
fn half_red_page() -> Vec<u8> {
let content = "1 0 0 rg 0 0 40 40 re f";
let objs = [
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 80 40] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /MediaBox [0 0 80 40] >>".to_vec(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, obj) in objs.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 = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn quadrant_png() -> Arc<Vec<u8>> {
let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]];
let image = image::RgbaImage::from_fn(80, 40, |x, y| image::Rgba(colours[usize::from(y >= 20) * 2 + usize::from(x >= 40)]));
let mut png = std::io::Cursor::new(Vec::new());
image.write_to(&mut png, image::ImageFormat::Png).unwrap();
Arc::new(png.into_inner())
}
fn assert_upright_quadrants(session: &Session, id: DocId, degrees: i64) {
let doc = session.get(id).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]];
for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip(colours) {
assert_eq!(stamp_pixel(&rendered, x, y), colour, "rotation {degrees}; displayed point ({x}, {y})");
}
}
/// #1398: an explicit rectangle keeps the caller's box and turns the picture upright.
#[test]
fn explicit_image_stamps_keep_displayed_orientation_on_rotated_pages() {
let bytes = quadrant_png();
for degrees in [0, 90, 180, 270] {
let (mut session, id) = rotated_blank(degrees);
let info = session.get(id).unwrap().info.pages[0].clone();
let rect = displayed_rect(&info, 100.0, 100.0, 80.0, 40.0);
session
.apply(
id,
Edit::AddCustomStamp {
page: 0,
rect,
name: "Original quadrants".into(),
file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 },
author: "Test".into(),
},
)
.unwrap();
assert_upright_quadrants(&session, id, degrees);
session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None, endings: None }).unwrap();
assert_upright_quadrants(&session, id, degrees);
}
}
/// #1398: a PDF page used as a stamp stays upright, at its natural size and in an explicit box.
#[test]
fn pdf_page_stamps_keep_displayed_orientation_on_rotated_pages() {
let bytes = Arc::new(half_red_page());
for (natural, degrees) in [true, false].into_iter().flat_map(|natural| [0, 90, 180, 270].map(|degrees| (natural, degrees))) {
let (mut session, id) = rotated_blank(degrees);
let info = session.get(id).unwrap().info.pages[0].clone();
let at = info.view_to_user(100.0, 100.0).map(f64::from);
let rect = if natural { [at[0], at[1], at[0], at[1]] } else { displayed_rect(&info, 100.0, 100.0, 80.0, 40.0) };
session
.apply(
id,
Edit::AddCustomStamp {
page: 0,
rect,
name: "Half red".into(),
file: MarkFile { name: "half.pdf".into(), bytes: bytes.clone(), page: 0 },
author: "Test".into(),
},
)
.unwrap();
let doc = session.get(id).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
let left = stamp_pixel(&rendered, 80, 100);
let right = stamp_pixel(&rendered, 120, 100);
assert!(left[0] > 200 && left[1] < 40, "rotation {degrees}, natural {natural}: left {left:?} should be red");
assert!(right.iter().all(|c| *c > 240), "rotation {degrees}, natural {natural}: right {right:?} should be paper");
}
}
fn page_texts(s: &Session, id: DocId) -> Vec<String> {
let doc = s.get(id).unwrap();
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
@ -1508,6 +1707,72 @@ fn preparing_a_form_adds_renames_and_deletes_fields() {
assert_eq!(names(&s), ["Text1", "Check Box1", "email"]);
}
/// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what
/// the page's /Resources holds.
fn one_line_page(resources: &str, body: &str) -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> /Contents 5 0 R >>"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// Put a redaction mark over `needle` where page 1 shows it, as Find text and redact does.
fn mark_shown_text(s: &mut Session, id: DocId, needle: &str) {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default());
let text = r.render(RenderRequestFor::text(0)).text.expect("the page has text");
let range = text.find(needle).pop().expect("the page shows the text");
let info = &doc.info.pages[0];
let quads = text.line_rects(range).into_iter().map(|v| pdfcraft_annot::rect_quad(info.view_rect_to_user(v).map(f64::from))).collect();
let shape = Shape::Redact { quads, overlay: String::new(), look: Default::default() };
let mark =
Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() });
s.apply(id, mark).unwrap();
}
/// Redaction removes text by the positions its own interpreter works out. Here the font and size
/// come from an ExtGState /Font entry (`gs`), which the renderer honours and the interpreter does
/// not model, so it places no glyph under the mark the user drew over the word as displayed: it
/// used to report success and leave the word in the file. The renderer is asked now, and the edit
/// is refused.
#[test]
fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() {
let line = "Public words then SECRET42 and more";
let mut s = Session::new().with_clock(|| 1_700_000_000);
let by_gs = one_line_page("/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET"));
let id = s.open("gs.pdf", None, Arc::new(by_gs), None).unwrap();
mark_shown_text(&mut s, id, "SECRET42");
let refused = s.apply(id, Edit::ApplyRedactions { pages: None });
assert!(matches!(refused, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0), "{refused:?}");
// Nothing was changed: the mark is still pending and the page still shows the line.
assert_eq!(s.get(id).unwrap().redaction_marks(), 1);
assert_eq!(page_texts(&s, id), [line]);
// The same line with its font set by `Tf`: the mark is applied and the word is gone.
let by_tf = one_line_page("/Font << /F1 4 0 R >>", &format!("BT /F1 16 Tf 20 150 Td ({line}) Tj ET"));
let id = s.open("tf.pdf", None, Arc::new(by_tf), None).unwrap();
mark_shown_text(&mut s, id, "SECRET42");
s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap();
let shown = page_texts(&s, id).swap_remove(0);
assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}");
}
#[test]
fn redaction_marks_apply_for_good_and_undo() {
let (mut s, id) = session_with(2);
@ -3246,6 +3511,68 @@ fn xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_
assert_eq!((v2("days_2"), v2("rest")), ("2".into(), "29".into()));
}
#[test]
fn a_value_a_script_sets_reaches_every_field_bound_to_the_same_data_node() {
// `carried` inside the area and a second `carried` outside it bind to one data node (areas
// are not data scopes). In document order: `setter` writes the inner one, `echo` reads the
// outer one in the same event, and the saved data holds the one value.
let tpl = pdfcraft_xfa::fixtures::leave_template("<form><page><carried>5</carried></page></form>")
.replace(
r#" <field name="rest" y="2.5in" w="2in" h="0.3in" access="readOnly">"#,
r#" <field name="setter" y="3in" w="2in" h="0.3in"><ui><numericEdit/></ui><calculate><script>carried = 9
9</script></calculate></field>
<field name="rest" y="2.5in" w="2in" h="0.3in" access="readOnly">"#,
)
.replace(
" </subform>\n </subform>\n</subform>",
r#" </subform>
<field name="carried" y="8.2in" w="2in" h="0.3in"><ui><numericEdit/></ui></field>
<field name="echo" y="8.5in" w="2in" h="0.3in"><ui><numericEdit/></ui><calculate><script>carried + 0</script></calculate></field>
</subform>
</subform>"#,
);
assert!(tpl.contains("name=\"echo\"") && tpl.contains("name=\"setter\""), "the fixture was reshaped");
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("shared.pdf", None, bytes, None).expect("opens");
let out = s.take_js_output(id);
assert!(out.errors.is_empty(), "{out:?}");
let values = |s: &Session, id: DocId, n: &str| {
s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with(n)).map(|f| f.value.join("")).collect::<Vec<_>>()
};
assert_eq!(values(&s, id, "carried"), vec!["9".to_string(), "9".to_string()], "both fields show the node's value");
assert_eq!(values(&s, id, "echo"), vec!["9".to_string()], "read in the same event the setter ran in");
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let d = pdfcraft_xfa::data_of(&cos).unwrap();
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].carried[0]")), Some("9"));
assert_eq!(d.count(&pdfcraft_xfa::som_to_path("form[0].page[0]"), "carried"), 1, "one data node");
// The inner field hidden (no widget): the outer widget still takes the node's value, and
// an unrelated edit does not write the old value back over it.
let hidden = Arc::new(pdfcraft_xfa::fixtures::shell(
&tpl.replace(r#"<field name="carried" y="1.5in""#, r#"<field name="carried" presence="hidden" y="1.5in""#),
));
let id = s.open("hidden.pdf", None, hidden, None).expect("opens");
assert_eq!(values(&s, id, "carried"), vec!["9".to_string()]);
assert_eq!(values(&s, id, "echo"), vec!["9".to_string()]);
s.apply(id, Edit::SetFieldValue { name: "allowance".into(), value: FieldValue::Text("31".into()) }).unwrap();
assert_eq!(values(&s, id, "carried"), vec!["9".to_string()]);
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
assert_eq!(pdfcraft_xfa::data_of(&cos).unwrap().text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].carried[0]")), Some("9"));
// A check box bound to the node a text field sets: checked by its own on state.
let checkbox = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl.replace(
r#"<field name="carried" y="1.5in" w="2in" h="0.3in"><ui><numericEdit/></ui></field>"#,
r#"<field name="carried" y="1.5in" w="2in" h="0.3in"><ui><checkButton/></ui><items><integer>9</integer><integer>0</integer></items></field>"#,
)));
let id = s.open("check.pdf", None, checkbox, None).expect("opens");
let carried: Vec<(pdfcraft_forms::FieldKind, Vec<String>)> =
s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("carried")).map(|f| (f.kind, f.value.clone())).collect();
assert_eq!(
carried,
vec![(pdfcraft_forms::FieldKind::CheckBox, vec!["9".to_string()]), (pdfcraft_forms::FieldKind::Text, vec!["9".to_string()])],
"{carried:?}"
);
}
#[test]
fn dynamic_xfa_forms_run_formcalc_scripts() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template()));

View File

@ -63,4 +63,16 @@ impl Document {
Ok(TextEditPreview { page, bytes, config: self.config.clone() })
})?
}
/// The page beneath an added item while it is dragged: `index` counts the page's added
/// items, as `Edit::DeleteContent` does. Read-only, like [`Self::text_edit_preview`].
pub fn added_content_preview(&self, page: usize, index: usize) -> Result<TextEditPreview, String> {
guard(|| {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let mut cos = editor.cos.clone();
pdfcraft_edit::delete_content(&mut cos, page, index).map_err(|e| e.to_string())?;
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
Ok(TextEditPreview { page, bytes, config: self.config.clone() })
})?
}
}

View File

@ -258,6 +258,8 @@ struct Runner<'a, 'b> {
/// Field values to write, by name (the last wins).
field_changes: std::collections::BTreeMap<String, Vec<String>>,
by_som: Option<HashMap<String, String>>,
/// Widget names by the data node they bind to (built with `by_som`).
by_path: Option<HashMap<pdfcraft_xfa::DataPath, Vec<String>>>,
fields: Option<HashMap<String, FieldInfo>>,
/// The live form as last built; dropped when a script changed the form.
cached: Option<LiveForm>,
@ -289,6 +291,7 @@ impl<'a, 'b> Runner<'a, 'b> {
pending: Vec::new(),
field_changes: Default::default(),
by_som: None,
by_path: None,
fields: None,
cached: None,
session: None,
@ -341,11 +344,26 @@ impl<'a, 'b> Runner<'a, 'b> {
fn by_som(&mut self) -> &HashMap<String, String> {
if self.by_som.is_none() {
self.by_som = Some(pdfcraft_xfa::fields_by_som(self.doc));
let by_som = pdfcraft_xfa::fields_by_som(self.doc);
let mut by_path: HashMap<pdfcraft_xfa::DataPath, Vec<String>> = HashMap::new();
for (som, name) in &by_som {
by_path.entry(pdfcraft_xfa::data_path(self.tpl, som)).or_default().push(name.clone());
}
for names in by_path.values_mut() {
names.sort();
}
self.by_path = Some(by_path);
self.by_som = Some(by_som);
}
self.by_som.get_or_insert_with(Default::default)
}
/// The widgets bound to the data node at `path`, in name order.
fn widgets_at(&mut self, path: &pdfcraft_xfa::DataPath) -> Vec<String> {
self.by_som();
self.by_path.as_ref().and_then(|m| m.get(path)).cloned().unwrap_or_default()
}
fn fields(&mut self) -> &mut HashMap<String, FieldInfo> {
if self.fields.is_none() {
self.fields = Some(field_infos(self.doc));
@ -387,55 +405,63 @@ impl<'a, 'b> Runner<'a, 'b> {
self.invalidate();
}
/// Give the object at `som` the value `value` (field `name` when it has a widget). Returns
/// whether it changed.
/// Give the object at `som` the value `value`: in the data, and in every widget bound to
/// that data node (each valued by its own kind: a check box by its on state, a list box by
/// lines). Returns whether anything changed.
fn set_value(&mut self, som: &str, value: &str) -> bool {
let path = pdfcraft_xfa::data_path(self.tpl, som);
let name = self.by_som().get(som).cloned();
let Some(name) = name else {
// No widget (a hidden field, a draw, a row not laid out yet): the data alone.
if self.data.as_ref().and_then(|d| d.text_at(&path)) == Some(value) {
return false;
}
self.value_op(som, path, value.to_string());
self.changes.values += 1;
return true;
};
let Some(info) = self.fields().get(&name).cloned() else { return false };
use pdfcraft_forms::FieldKind as K;
let new_value: Vec<String> = match info.kind {
K::CheckBox => {
if pdfcraft_xfa::data::is_on(value, &info.on_state) || value == info.on_state {
vec![info.on_state.clone()]
} else {
Vec::new()
let path = pdfcraft_xfa::data_path(self.tpl, som);
let names = self.widgets_at(&path);
// The data text: the widget at `som` says how its kind writes the value; without one,
// the first widget bound to the node, else the value itself.
let primary = self.by_som().get(som).cloned().or_else(|| names.first().cloned());
let mut text = value.to_string();
let mut changed = false;
for name in &names {
let Some(info) = self.fields().get(name).cloned() else { continue };
let new_value: Vec<String> = match info.kind {
K::CheckBox => {
if pdfcraft_xfa::data::is_on(value, &info.on_state) || value == info.on_state {
vec![info.on_state.clone()]
} else {
Vec::new()
}
}
K::Radio => {
let v = value.trim();
if v.is_empty() { Vec::new() } else { vec![v.to_string()] }
}
// Read-only fields still take values from scripts (calculated totals are
// read-only); buttons and signatures hold none.
K::PushButton | K::Signature => continue,
K::List if info.multi => value.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect(),
_ => vec![value.to_string()],
};
let same = match info.kind {
K::CheckBox => info.value.is_empty() == new_value.is_empty(),
K::Radio => info.value.first() == new_value.first(),
K::List if info.multi => info.value == new_value,
_ => info.value.first().map_or(value.is_empty(), |v| v == value),
};
let updated = FieldInfo { value: new_value.clone(), ..info };
if primary.as_deref() == Some(name.as_str()) {
text = data_text(&updated);
}
K::Radio => {
let v = value.trim();
if v.is_empty() { Vec::new() } else { vec![v.to_string()] }
if same {
continue;
}
// Read-only fields still take values from scripts (calculated totals are read-only).
K::PushButton | K::Signature => return false,
K::List if info.multi => value.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect(),
_ => vec![value.to_string()],
};
let same = match info.kind {
K::CheckBox => info.value.is_empty() == new_value.is_empty(),
K::Radio => info.value.first() == new_value.first(),
K::List if info.multi => info.value == new_value,
_ => info.value.first().map_or(value.is_empty(), |v| v == value),
};
if same {
return false;
self.fields().insert(name.clone(), updated);
self.field_changes.insert(name.clone(), new_value);
changed = true;
}
let updated = FieldInfo { value: new_value.clone(), ..info };
let text = data_text(&updated);
self.fields().insert(name.clone(), updated);
self.field_changes.insert(name, new_value);
self.value_op(som, path, text);
self.changes.values += 1;
true
if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text {
self.value_op(som, path, text);
changed = true;
}
if changed {
self.changes.values += 1;
}
changed
}
/// Write what the scripts changed so far: the data as one rewrite, the field values, the
@ -489,6 +515,7 @@ impl<'a, 'b> Runner<'a, 'b> {
crate::xfa_redraw(self.doc)?;
self.console(crate::undrawn_field_warnings(self.doc));
self.by_som = None;
self.by_path = None;
self.fields = None;
self.invalidate();
Ok(())

View File

@ -0,0 +1,370 @@
//! The one chain PdfCraft follows when a document does not embed a font program, and a record of
//! what it chose and why.
//!
//! Substitution used to be decided per caller and never written down: `edit` picked a craft face
//! by the script of the text it was drawing, `Std14::from_base_font` matched Latin names, and the
//! renderer did its own thing. Nothing told the *user* that the glyphs they are looking at are not
//! the ones the document asked for, which is the difference between "PdfCraft shows nothing for my
//! Chinese PDF" being a mystery and being a sentence.
//!
//! This module answers the *dictionary* question — "the document does not carry this program; what
//! will stand in for it, and why?" — which is what inspection and reporting need. It deliberately
//! does not answer the *text* question ("which face has a glyph for こ?"), because that depends on
//! the string being drawn and is already handled where the drawing happens. It delegates to the
//! existing per-script selectors rather than restating their order, so there is one source of
//! truth for each.
//!
//! The chain is tried in order, and the first step that yields a face wins:
//!
//! 1. The document embeds the program. Nothing is substituted.
//! 2. `/BaseFont` names one of the standard 14 (or a well-known alias such as Arial). Exact
//! published metrics apply, so this is as close to right as a substitution gets.
//! 3. craft-fonts holds that exact family. Never another family — see [`crate::embedding`].
//! 4. The descendant font's `/CIDSystemInfo /Ordering` names a script, and craft-fonts has a face
//! for it. Today that means Adobe-Japan1.
//! 5. The ordering names a script we have **no** licensed face for (GB1, CNS1, Korea1). There is
//! no honest substitute, so the chain reports none: the caller draws nothing and can say why.
//! 6. Otherwise, the nearest of the standard 14 by `/FontDescriptor /Flags` — serif, fixed pitch,
//! bold and italic. This step always succeeds, so a simple font is never left without a face.
use crate::{CraftFont, Std14};
/// What a font dictionary asks for, as far as the dictionary alone can say.
#[derive(Clone, Copy, Debug, Default)]
pub struct Request<'a> {
/// `/BaseFont` with any `ABCDEF+` subset tag already stripped.
pub base_font: &'a str,
/// Whether the descriptor carries `/FontFile`, `/FontFile2` or `/FontFile3` (or it is Type 3).
pub embedded: bool,
/// `/FontDescriptor /Flags`, or 0 when there is no descriptor.
pub flags: u32,
/// The descendant font's `/CIDSystemInfo /Ordering`, for a composite font.
pub ordering: Option<&'a str>,
}
/// The face the chain picked.
///
/// `Debug`, `PartialEq` and `Eq` are written by hand rather than derived, because [`CraftFont`]
/// carries a whole font program: deriving would print megabytes and compare font files.
#[derive(Clone, Copy)]
pub enum Face {
/// The document's own program; nothing was substituted.
Embedded,
/// One of the standard 14, whose metrics are exact.
Standard14(Std14),
/// A face from the optional craft-fonts build input.
Craft(&'static CraftFont),
/// No face at all. The caller must draw nothing rather than draw the wrong glyphs.
None,
}
impl PartialEq for Face {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Embedded, Self::Embedded) | (Self::None, Self::None) => true,
(Self::Standard14(a), Self::Standard14(b)) => a == b,
// craft-fonts entries are unique statics, so identity is the comparison that means
// what we want: two styles of one family are different faces and must not be equal.
(Self::Craft(a), Self::Craft(b)) => std::ptr::eq(*a, *b),
_ => false,
}
}
}
impl Eq for Face {}
impl std::fmt::Debug for Face {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Embedded => f.write_str("Embedded"),
Self::Standard14(s) => write!(f, "Standard14({})", s.base_font()),
// The name, never the bytes.
Self::Craft(c) => write!(f, "Craft({})", c.name()),
Self::None => f.write_str("None"),
}
}
}
/// Why the chain picked that face. The variants are in the order the chain tries them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Why {
/// The document carries the font program.
Embedded,
/// `/BaseFont` names a standard-14 face or a well-known alias of one.
NamedStandard14,
/// `/BaseFont` is otherwise unknown, but its name hints at a family ("Serif", "Roman",
/// "Mono"). A guess from the name, and reported as one.
NameHintedStandard14,
/// craft-fonts holds this exact family.
ExactCraftFamily,
/// A craft-fonts face for the script named by `/CIDSystemInfo /Ordering`.
ScriptFace,
/// The ordering names a script with no licensed face available.
NoFaceForScript,
/// The nearest standard-14 face by descriptor flags.
StyleMatched,
}
impl Why {
/// A stable sentence for a report, written for whoever has to act on it.
pub fn note(self) -> &'static str {
match self {
Self::Embedded => "embedded in the document; not substituted",
Self::NamedStandard14 => "names one of the standard 14 fonts, or a well-known alias of one, whose metrics are exact",
Self::NameHintedStandard14 => {
"not embedded and not a known family, but its name hints at one (Serif, Roman or Mono), so the nearest standard-14 face was chosen from that hint"
}
Self::ExactCraftFamily => "this exact family is available, so no other family was chosen",
Self::ScriptFace => "substituted by a face for the script the font declares",
Self::NoFaceForScript => {
"no licensed face is available for the script this font declares, so nothing is drawn for it \
rather than the wrong glyphs"
}
Self::StyleMatched => "not embedded and not a known family, so the nearest standard-14 face was chosen by its descriptor flags",
}
}
/// Whether the document's own glyphs are being shown. Only [`Why::Embedded`] is faithful.
pub fn is_faithful(self) -> bool {
matches!(self, Self::Embedded)
}
}
/// What the chain chose, and why.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Substitution {
pub face: Face,
pub why: Why,
}
impl Substitution {
/// The chosen face's name, for a report. `None` when nothing was chosen.
pub fn face_name(&self) -> Option<String> {
match self.face {
Face::Embedded => None,
Face::Standard14(f) => Some(f.base_font().to_owned()),
Face::Craft(f) => Some(f.name()),
Face::None => None,
}
}
}
/// CID orderings we recognise, and whether a craft-fonts face may exist for them.
///
/// `Identity` is not a script: it says the codes are glyph ids, so it tells us nothing about
/// which face would suit and the chain falls through to the flags.
fn script_for(ordering: &str) -> Option<&'static str> {
match ordering {
"Japan1" => Some("Jpan"),
// Registered orderings we have no openly licensed face for. AGENTS.md 1.1 rules out
// Source Han and Noto CJK (Source Han rebranded), which is why these have none.
"GB1" | "CNS1" | "Korea1" | "KR" => Some(""),
_ => None,
}
}
/// Apply the chain to `req`. Total: every request gets a [`Substitution`].
///
/// See the module documentation for the order and the reasoning behind each step.
pub fn substitute(req: &Request<'_>) -> Substitution {
let at = |face, why| Substitution { face, why };
if req.embedded {
return at(Face::Embedded, Why::Embedded);
}
if let Some((f, how)) = Std14::recognise(req.base_font) {
return match how {
crate::std14::Match::Alias => at(Face::Standard14(f), Why::NamedStandard14),
crate::std14::Match::FamilyHint => at(Face::Standard14(f), Why::NameHintedStandard14),
};
}
if let Some(f) = crate::embedding::matching_faces(req.base_font).first() {
return at(Face::Craft(f), Why::ExactCraftFamily);
}
if let Some(script) = req.ordering.and_then(script_for) {
// A script we hold a face for; otherwise there is no honest substitute to offer.
return match crate::CRAFT_FONTS.iter().find(|f| f.covers(script)) {
Some(f) => at(Face::Craft(f), Why::ScriptFace),
None => at(Face::None, Why::NoFaceForScript),
};
}
at(Face::Standard14(style_matched(req.flags)), Why::StyleMatched)
}
/// The nearest standard-14 face for `/FontDescriptor /Flags` (ISO 32000-2 table 121): fixed pitch
/// is bit 1, serif bit 2, italic bit 7 and ForceBold bit 19.
fn style_matched(flags: u32) -> Std14 {
let (bold, italic) = (flags & 0x4_0000 != 0, flags & 0x40 != 0);
match (flags & 1 != 0, flags & 2 != 0, bold, italic) {
(true, _, false, false) => Std14::Courier,
(true, _, true, false) => Std14::CourierBold,
(true, _, false, true) => Std14::CourierOblique,
(true, _, true, true) => Std14::CourierBoldOblique,
(false, true, false, false) => Std14::TimesRoman,
(false, true, true, false) => Std14::TimesBold,
(false, true, false, true) => Std14::TimesItalic,
(false, true, true, true) => Std14::TimesBoldItalic,
(false, false, false, false) => Std14::Helvetica,
(false, false, true, false) => Std14::HelveticaBold,
(false, false, false, true) => Std14::HelveticaOblique,
(false, false, true, true) => Std14::HelveticaBoldOblique,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn req<'a>(base: &'a str, embedded: bool, flags: u32, ordering: Option<&'a str>) -> Request<'a> {
Request { base_font: base, embedded, flags, ordering }
}
#[test]
fn an_embedded_font_is_never_substituted() {
// True even when the name and flags would otherwise pick something: embedding wins first.
for (base, ordering) in [("Helvetica", None), ("Whatever", Some("GB1")), ("", None)] {
let s = substitute(&req(base, true, 0, ordering));
assert_eq!(s, Substitution { face: Face::Embedded, why: Why::Embedded });
assert!(s.why.is_faithful());
assert_eq!(s.face_name(), None, "nothing stands in, so there is no substitute to name");
}
}
#[test]
fn a_standard_14_name_wins_over_the_flags() {
// Flags claiming a fixed-pitch serif must not override a name we have exact metrics for.
let s = substitute(&req("Times-BoldItalic", false, 0b11, None));
assert_eq!(s.face, Face::Standard14(Std14::TimesBoldItalic));
assert_eq!(s.why, Why::NamedStandard14);
assert_eq!(s.face_name().as_deref(), Some("Times-BoldItalic"));
// Aliases and subset tags resolve the same way.
for name in ["Arial", "ArialMT", "Arial-Bold", "Symbol", "ZapfDingbats", "CourierNew"] {
assert_eq!(substitute(&req(name, false, 0, None)).why, Why::NamedStandard14, "{name}");
}
}
#[test]
fn a_name_hint_is_reported_as_a_guess_not_as_a_standard_14_name() {
// A house font whose name merely contains "Serif" gets Times, which is a sensible guess --
// but calling that "names one of the standard 14" would tell the reader something untrue.
let hinted = substitute(&req("SomeHouseSerif", false, 0, None));
assert_eq!(hinted.face, Face::Standard14(Std14::TimesRoman));
assert_eq!(hinted.why, Why::NameHintedStandard14);
assert!(hinted.why.note().contains("hints at one"), "{}", hinted.why.note());
// Style words still apply to the guess.
assert_eq!(substitute(&req("SomeHouseSerif-BoldItalic", false, 0, None)).face, Face::Standard14(Std14::TimesBoldItalic));
// "Mono" hints at Courier the same way.
assert_eq!(substitute(&req("HouseMono", false, 0, None)).why, Why::NameHintedStandard14);
// A real alias is still reported as the request it is.
for alias in ["Arial", "CourierNew", "Times-Roman", "Symbol"] {
assert_eq!(substitute(&req(alias, false, 0, None)).why, Why::NamedStandard14, "{alias}");
}
// A name with no hint at all falls through to the flags instead.
assert_eq!(substitute(&req("HouseGrotesk", false, 0b10, None)).why, Why::StyleMatched);
}
#[test]
fn flags_pick_the_nearest_of_the_fourteen() {
// Bit 1 fixed pitch, bit 2 serif, bit 7 italic, bit 19 ForceBold.
let cases = [
(0, Std14::Helvetica),
(0b10, Std14::TimesRoman),
(0b1, Std14::Courier),
(0x40, Std14::HelveticaOblique),
(0x4_0000, Std14::HelveticaBold),
(0x4_0040, Std14::HelveticaBoldOblique),
(0b10 | 0x40, Std14::TimesItalic),
(0b10 | 0x4_0000, Std14::TimesBold),
(0b10 | 0x4_0040, Std14::TimesBoldItalic),
(0b1 | 0x40, Std14::CourierOblique),
(0b1 | 0x4_0000, Std14::CourierBold),
(0b1 | 0x4_0040, Std14::CourierBoldOblique),
// Fixed pitch and serif together: monospace is the stronger signal for layout.
(0b11, Std14::Courier),
];
for (flags, want) in cases {
let s = substitute(&req("SomethingUnknown", false, flags, None));
assert_eq!(s.face, Face::Standard14(want), "flags {flags:#x}");
assert_eq!(s.why, Why::StyleMatched);
}
}
#[test]
fn a_script_with_no_licensed_face_reports_none_rather_than_latin() {
// The user's Chinese PDF draws nothing. The point of this is that we can now say so,
// instead of quietly standing Helvetica in and showing the wrong glyphs.
for ordering in ["GB1", "CNS1", "Korea1", "KR"] {
let s = substitute(&req("SomeCJKFont", false, 0, Some(ordering)));
assert_eq!(s.face, Face::None, "{ordering}");
assert_eq!(s.why, Why::NoFaceForScript, "{ordering}");
assert_eq!(s.face_name(), None);
assert!(!s.why.is_faithful());
assert!(s.why.note().contains("no licensed face"), "{ordering}");
}
}
#[test]
fn identity_ordering_is_not_a_script_and_falls_through_to_the_flags() {
// /Ordering (Identity) says the codes are glyph ids. It implies nothing about the face,
// so treating it as a script would invent a substitution the dictionary never asked for.
let s = substitute(&req("SomeSubsetFont", false, 0b10, Some("Identity")));
assert_eq!(s.face, Face::Standard14(Std14::TimesRoman));
assert_eq!(s.why, Why::StyleMatched);
// An unregistered ordering is treated the same way, not guessed at.
assert_eq!(substitute(&req("X", false, 0, Some("MadeUp1"))).why, Why::StyleMatched);
}
#[test]
fn japanese_depends_on_craft_fonts_and_says_which_it_got() {
// craft-fonts is an optional build input, so both outcomes are correct -- but they must be
// distinguishable, and neither may be a silent Latin stand-in.
let s = substitute(&req("SomeJapaneseFont", false, 0, Some("Japan1")));
if crate::CRAFT_FONTS.iter().any(|f| f.covers("Jpan")) {
assert_eq!(s.why, Why::ScriptFace);
assert!(matches!(s.face, Face::Craft(_)));
assert!(s.face_name().is_some_and(|n| !n.is_empty()));
} else {
assert_eq!(s.why, Why::NoFaceForScript);
assert_eq!(s.face, Face::None);
}
assert!(!s.why.is_faithful(), "a substitution is never faithful");
}
#[test]
fn every_why_has_a_distinct_usable_note() {
let all = [
Why::Embedded,
Why::NamedStandard14,
Why::NameHintedStandard14,
Why::ExactCraftFamily,
Why::ScriptFace,
Why::NoFaceForScript,
Why::StyleMatched,
];
let mut notes: Vec<&str> = all.iter().map(|w| w.note()).collect();
notes.sort_unstable();
let before = notes.len();
notes.dedup();
assert_eq!(notes.len(), before, "each reason reads differently");
assert!(all.iter().all(|w| !w.note().is_empty() && w.note().len() > 20), "notes are sentences, not labels");
assert_eq!(all.iter().filter(|w| w.is_faithful()).count(), 1, "only an embedded program is faithful");
}
#[test]
fn hostile_input_is_total_and_never_panics() {
// Every field is attacker-controlled: a name is any bytes the document chose.
let long = "a".repeat(10_000);
let names = ["", " ", "+", "ABCDEF+", "\u{0}\u{1}", long.as_str(), "../../etc/passwd", "Ĥéļvéťïçà"];
for name in names {
for flags in [0, u32::MAX, 0x4_0040, 1] {
for ordering in [None, Some(""), Some("Japan1"), Some("\u{0}")] {
let s = substitute(&req(name, false, flags, ordering));
// Always a decision, and the reason always reads.
assert!(!s.why.note().is_empty());
assert_eq!(s.face == Face::None, s.why == Why::NoFaceForScript, "only one step declines: {name:?}");
}
}
}
}
}

View File

@ -1,17 +1,22 @@
//! pdfcraft-fonts — font metrics and encodings for generated appearances (L2).
//!
//! See the README: the metrics are approximations by character class (no vendor metrics files
//! are bundled). The full font subsystem lands in M2.2/M7.
//! Standard-14 advance widths are exact (`Std14`, ISO 32000-2 Annex D). What remains approximate
//! is everything that needs a font *program*: this crate reads font dictionaries, never embedded
//! programs, so it has no glyph outlines for document fonts and cannot subset or re-embed. That
//! is phase F 4.2 in docs/plan/execution-plan.md.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod craft;
pub mod embedding;
mod encodings;
pub mod fallback;
pub mod pdf;
mod script;
mod shaping;
pub mod source_shaping;
mod std14;
mod std14_metrics;
pub use craft::{
CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_hebrew_font, document_japanese_font, document_japanese_font_for_style,
document_japanese_fonts_for_style, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, ui_japanese_fonts,
@ -21,27 +26,14 @@ pub use script::{
GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_missing, script_outline,
};
pub use shaping::{ShapedCluster, ShapedScript, arabic_glyph, arabic_has, shape_arabic, shape_thai, shaped_glyph, thai_has};
pub use std14::Std14;
/// Approximate advance of `s` in Helvetica (or Arial) at `size` points.
/// Advance of `s` in Helvetica at `size` points, using the exact standard-14 metrics.
///
/// Kept as a free function because generated appearances overwhelmingly use Helvetica; reach for
/// [`Std14`] directly for any other face, and never for a scaled approximation of this one.
pub fn helvetica_width(s: &str, size: f64) -> f64 {
let units: f64 = s
.chars()
.map(|c| match c {
' ' | 'i' | 'j' | 'l' | '\'' | '!' | '|' | '.' | ',' | ':' | ';' | 'I' => 260.0,
'f' | 't' | 'r' | '(' | ')' | '[' | ']' | '/' | '-' | '"' => 333.0,
'm' => 833.0,
'w' => 722.0,
'M' => 833.0,
'W' => 944.0,
'J' | 'c' | 'k' | 's' | 'v' | 'x' | 'y' | 'z' => 500.0,
'0'..='9' | 'a'..='z' | '$' | '#' | '?' | '_' => 556.0,
'A'..='Z' => 680.0,
'@' => 1015.0,
_ if c.is_whitespace() => 260.0,
_ => 584.0,
})
.sum();
units * size / 1000.0
Std14::Helvetica.text_width(s, size)
}
/// Approximate advance of `s` at `size` points in a CJK font: full-width characters take one

View File

@ -1,9 +1,15 @@
//! What content editing and redaction need from a PDF font: how to split a string into character
//! codes, each code's advance width, the glyph height (ascent, descent), and what each code means
//! (Unicode) — and back, which text the font can show. Widths come from the font dictionary
//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); the standard 14 fonts without widths use
//! approximations. Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and
//! `/Differences` glyph names, ISO 32000-2 Annex D).
//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a standard-14 font with no `/Widths` uses the exact
//! published metrics ([`crate::Std14`]).
//!
//! Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and `/Differences` glyph
//! names, ISO 32000-2 Annex D). A code may deliberately have **no** meaning: where the font's own
//! built-in encoding governs and we hold no table for it — Symbol, ZapfDingbats, MacExpert — this
//! module reports nothing rather than the Latin letter at that code. Callers must treat a missing
//! meaning as "unknown", never as "absent": `redact` matches on this map, and a wrong character
//! there is worse than no character.
use std::borrow::Cow;
use std::collections::{BinaryHeap, HashMap, HashSet};
@ -19,14 +25,6 @@ enum Codes {
Ranges(Vec<(usize, Vec<u8>, Vec<u8>)>),
}
#[derive(Clone, Debug, PartialEq)]
enum Std14 {
Helvetica,
Times,
Courier,
Symbolic,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Metrics {
codes: Codes,
@ -39,7 +37,8 @@ pub struct Metrics {
/// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`).
cid_map: Vec<(u32, u32, u32)>,
default: f64,
std14: Option<Std14>,
/// The standard-14 face to measure with when the dictionary carries no `/Widths`.
std14: Option<crate::Std14>,
/// Glyph units → text space (0.001, or `/FontMatrix[0]` for Type 3).
pub scale: f64,
/// Glyph box in text space per unit of font size.
@ -526,7 +525,7 @@ impl Metrics {
cid_ranges: Vec::new(),
cid_map: Vec::new(),
default: 500.0,
std14: Some(Std14::Helvetica),
std14: Some(crate::Std14::Helvetica),
scale: 0.001,
ascent: 0.9,
descent: -0.25,
@ -655,16 +654,10 @@ impl Metrics {
}
}
} else if m.widths.is_empty() {
let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).to_ascii_lowercase();
m.std14 = Some(if base.contains("courier") {
Std14::Courier
} else if base.contains("times") {
Std14::Times
} else if base.contains("symbol") || base.contains("dingbats") {
Std14::Symbolic
} else {
Std14::Helvetica
});
let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).into_owned();
// The real face, bold and italic included: the published metrics differ by style,
// so Times-Bold is not Times-Roman and neither is Helvetica scaled.
m.std14 = crate::Std14::from_base_font(&base).or(Some(crate::Std14::Helvetica));
}
}
if let Some(d) = descriptor {
@ -726,14 +719,13 @@ impl Metrics {
if let Some(w) = code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) {
return w * self.scale;
}
match &self.std14 {
Some(Std14::Courier) => 0.6,
Some(Std14::Symbolic) => 0.75,
Some(f) => {
let c = char::from_u32(code).filter(|c| !c.is_control()).unwrap_or('n');
let w = crate::helvetica_width(&c.to_string(), 1.0);
if *f == Std14::Times { w * 0.92 } else { w }
}
match self.std14 {
// A font with no /Widths is one of the standard 14; use its published metrics.
// Codes the font's encoding leaves undefined fall back to /MissingWidth.
Some(f) => match u8::try_from(code).map(|c| f.width(c)).unwrap_or(0.0) {
0.0 => self.default * self.scale,
w => w / 1000.0,
},
None => self.default * self.scale,
}
}
@ -1006,13 +998,49 @@ fn parse_to_unicode_within(data: &[u8], out: &mut Unicode, mut budget: usize) {
out.explicit_low = bit_set_below_64k(out.explicit.keys().copied());
}
/// A glyph name's Unicode (Annex D names, `uniXXXX`, `uXXXX[XX]`, single letters).
pub fn glyph_unicode(name: &str) -> Option<char> {
if let Ok(i) = crate::encodings::NAMES.binary_search_by(|(n, _)| (*n).cmp(name)) {
return char::from_u32(crate::encodings::NAMES[i].1);
/// A glyph name's text, by the Adobe Glyph List *algorithm* — the published naming rules, not
/// the AGL data table, which this crate does not hold.
///
/// A name is cut at its first `.`, because a suffix marks a variant of the same character rather
/// than a different one (`a.sc` is small-capital `a`, `one.oldstyle` is still `1`). What remains
/// is split on `_` into components, so a ligature name yields several characters (`f_i` is "fi").
/// Each component is read as an Annex D name, as `uniXXXX` with one or more four-hex-digit values,
/// or as `uXXXX` through `uXXXXXX`.
///
/// Returns `None` rather than a partial answer: a name is either fully understood or not, and half
/// a ligature would be worse than admitting we cannot read it.
pub fn glyph_text(name: &str) -> Option<String> {
let stem = name.split('.').next().filter(|s| !s.is_empty())?;
let mut out = String::new();
for part in stem.split('_') {
out.push_str(&glyph_component(part)?);
}
let hex = name.strip_prefix("uni").filter(|h| h.len() == 4).or_else(|| name.strip_prefix('u').filter(|h| (4..=6).contains(&h.len())))?;
u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
Some(out).filter(|s| !s.is_empty())
}
/// One `_`-separated component of a glyph name.
fn glyph_component(part: &str) -> Option<String> {
if let Ok(i) = crate::encodings::NAMES.binary_search_by(|(n, _)| (*n).cmp(part)) {
return char::from_u32(crate::encodings::NAMES[i].1).map(String::from);
}
// `from_str_radix` accepts a leading `+`, which is not a glyph name, so the digits are checked.
let hexish = |h: &str| !h.is_empty() && h.bytes().all(|b| b.is_ascii_hexdigit());
if let Some(hex) = part.strip_prefix("uni").filter(|h| h.len() >= 4 && h.len() % 4 == 0 && hexish(h)) {
let mut s = String::new();
for i in (0..hex.len()).step_by(4) {
// A lone surrogate half is not a character; such a name is not usable text.
s.push(char::from_u32(u32::from_str_radix(hex.get(i..i.saturating_add(4))?, 16).ok()?)?);
}
return Some(s);
}
let hex = part.strip_prefix('u').filter(|h| (4..=6).contains(&h.len()) && hexish(h))?;
u32::from_str_radix(hex, 16).ok().and_then(char::from_u32).map(String::from)
}
/// [`glyph_text`] for names that stand for exactly one character.
pub fn glyph_unicode(name: &str) -> Option<char> {
let mut chars = glyph_text(name)?.chars().collect::<Vec<_>>().into_iter();
chars.next().filter(|_| chars.len() == 0)
}
/// Code → Unicode for a font: its ToUnicode CMap, else (simple fonts) its encoding.
@ -1025,12 +1053,33 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
Some(Object::Dict(d)) => d.name(b"BaseEncoding").map(<[u8]>::to_vec),
_ => None,
};
let table: &[u32; 256] = match base_name.as_deref() {
Some(b"WinAnsiEncoding") => &crate::encodings::WIN_ANSI,
Some(b"MacRomanEncoding") => &crate::encodings::MAC_ROMAN,
_ => &crate::encodings::STANDARD,
// Symbol and ZapfDingbats carry their own built-in encodings, which are not Latin
// (ISO 32000-2 9.6.6.1): Symbol code 0x61 is `alpha`, not `a`. We do not hold those two
// tables, so with no base encoding named they get no implicit mapping at all. Reporting
// nothing is honest; reporting Latin letters silently corrupts text extraction, find,
// copy and — because `redact` matches on this map — redaction.
//
// Deliberately keyed on the *name*, not on 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 on a large share of real documents.
// An explicitly named base encoding is a deliberate statement and is always honoured,
// even on Symbol.
let symbolic_builtin = matches!(
font.name(b"BaseFont").map(|b| crate::Std14::from_base_font(&String::from_utf8_lossy(b))),
Some(Some(crate::Std14::Symbol | crate::Std14::ZapfDingbats))
);
let table: Option<&[u32; 256]> = match base_name.as_deref() {
Some(b"WinAnsiEncoding") => Some(&crate::encodings::WIN_ANSI),
Some(b"MacRomanEncoding") => Some(&crate::encodings::MAC_ROMAN),
Some(b"StandardEncoding") => Some(&crate::encodings::STANDARD),
// MacExpert is small caps, oldstyle figures and fractions, not Latin at these codes,
// and we hold no table for it either.
Some(b"MacExpertEncoding") => None,
_ if symbolic_builtin => None,
// Standard is the default for a simple font that names no base encoding.
_ => Some(&crate::encodings::STANDARD),
};
for (code, u) in table.iter().enumerate() {
for (code, u) in table.into_iter().flatten().enumerate() {
if let Some(c) = char::from_u32(*u).filter(|_| *u != 0) {
out.encoding.insert(code as u32, c.to_string());
}
@ -1044,8 +1093,10 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
// Out-of-range codes map nothing a simple font can show.
Object::Int(n) => code = u32::try_from((*n).max(0)).unwrap_or(u32::MAX),
Object::Name(n) => {
match glyph_unicode(&String::from_utf8_lossy(n)) {
Some(c) => out.encoding.insert(code, c.to_string()),
// A name we cannot read removes any inherited meaning for that code,
// rather than leaving the base encoding's letter standing in for it.
match glyph_text(&String::from_utf8_lossy(n)) {
Some(t) => out.encoding.insert(code, t),
None => out.encoding.remove(&code),
};
code = code.saturating_add(1);
@ -1594,3 +1645,137 @@ mod tests {
assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true));
}
}
#[cfg(test)]
mod encoding_tests {
use pdfcraft_cos::{Dict, Document, Object};
use super::*;
/// A simple Type 1 font, optionally symbolic-flagged and optionally with an `/Encoding`.
fn simple(base: &str, flags: Option<i64>, enc: Option<Object>) -> (Document, Dict) {
let doc = Document::new_empty();
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Font"));
d.set(b"Subtype".to_vec(), Object::name("Type1"));
d.set(b"BaseFont".to_vec(), Object::name(base));
if let Some(f) = flags {
let mut desc = Dict::new();
desc.set(b"Flags".to_vec(), Object::Int(f));
d.set(b"FontDescriptor".to_vec(), Object::Dict(desc));
}
if let Some(e) = enc {
d.set(b"Encoding".to_vec(), e);
}
(doc, d)
}
fn differences(items: Vec<Object>) -> Object {
let mut enc = Dict::new();
enc.set(b"Differences".to_vec(), Object::Array(items));
Object::Dict(enc)
}
#[test]
fn glyph_names_follow_the_naming_algorithm() {
// Annex D names and the two hex forms, as before.
assert_eq!(glyph_text("quotedblleft").as_deref(), Some("\u{201c}"));
assert_eq!(glyph_text("uni20AC").as_deref(), Some("\u{20ac}"));
assert_eq!(glyph_text("u1F600").as_deref(), Some("\u{1f600}"));
// A suffix marks a variant of the same character, so the stem still reads.
assert_eq!(glyph_text("a.sc").as_deref(), Some("a"));
assert_eq!(glyph_text("one.oldstyle").as_deref(), Some("1"));
assert_eq!(glyph_text("A.alt017").as_deref(), Some("A"));
// `_` joins components, so a ligature name is more than one character.
assert_eq!(glyph_text("f_i").as_deref(), Some("fi"));
assert_eq!(glyph_text("f_f_l").as_deref(), Some("ffl"));
assert_eq!(glyph_text("a_uni0301").as_deref(), Some("a\u{301}"));
// `uniXXXX` carries several values, so one name can be a whole cluster.
assert_eq!(glyph_text("uni00660069").as_deref(), Some("fi"));
assert_eq!(glyph_text("uni004100300041").as_deref(), Some("A0A"));
// Both forms combine with a suffix.
assert_eq!(glyph_text("uni00660069.alt").as_deref(), Some("fi"));
// Not names: unknown, empty, a half-read ligature, bad hex, and the `+` that
// `from_str_radix` would otherwise accept.
assert_eq!(glyph_text("nonsense"), None);
assert_eq!(glyph_text(""), None);
assert_eq!(glyph_text(".notdef"), None);
assert_eq!(glyph_text("f_nonsense"), None, "half a ligature is not an answer");
assert_eq!(glyph_text("uni00"), None, "not a whole four-digit value");
assert_eq!(glyph_text("uni0041004"), None);
assert_eq!(glyph_text("uni+041"), None, "a sign is not a hex digit");
assert_eq!(glyph_text("u+0041"), None);
assert_eq!(glyph_text("uniD800"), None, "a lone surrogate half is not a character");
// Glyph-index names carry no meaning, and must not be guessed at.
assert_eq!(glyph_text("g3"), None);
assert_eq!(glyph_text("index42"), None);
// `glyph_unicode` is the single-character view of the same algorithm.
assert_eq!(glyph_unicode("uni20AC"), Some('\u{20ac}'));
assert_eq!(glyph_unicode("a.sc"), Some('a'));
assert_eq!(glyph_unicode("f_i"), None, "a ligature is not one character");
}
#[test]
fn a_ligature_or_unreadable_name_in_differences_is_handled() {
// A ligature name maps one code to two characters, so text extraction reads "fi".
let (doc, d) = simple("Custom", None, Some(differences(vec![Object::Int(65), Object::name("f_i")])));
let m = Metrics::from_dict(&doc, &d);
assert_eq!(m.decode(b"A"), "fi");
// An unreadable name must *remove* the base encoding's letter rather than leave it
// standing in: code 65 is no longer "A", because the font says it is something else.
let (doc, d) = simple("Custom", None, Some(differences(vec![Object::Int(65), Object::name("g7")])));
let m = Metrics::from_dict(&doc, &d);
assert_eq!(m.text_of(65), None);
assert_eq!(m.text_of(66).as_deref(), Some("B"), "neighbouring codes keep their meaning");
}
#[test]
fn symbol_and_dingbats_are_not_read_as_latin() {
// Symbol's built-in encoding puts `alpha` at 0x61. We hold no table for it, so the honest
// answer is none at all -- never "a", which would corrupt find, copy and redaction.
for base in ["Symbol", "ZapfDingbats", "ABCDEF+Symbol"] {
let (doc, d) = simple(base, Some(4), None);
let m = Metrics::from_dict(&doc, &d);
assert_eq!(m.text_of(0x61), None, "{base} must not claim 'a'");
assert_eq!(m.decode(b"abg"), "", "{base}");
}
// MacExpert is small caps and oldstyle figures at these codes, and we hold no table.
let (doc, d) = simple("Times-Roman", None, Some(Object::name("MacExpertEncoding")));
let m = Metrics::from_dict(&doc, &d);
assert_eq!(m.text_of(0x61), None);
// /Differences still applies on top of a built-in encoding we cannot read.
let (doc, d) = simple("Symbol", Some(4), Some(differences(vec![Object::Int(0x61), Object::name("uni03B1")])));
let m = Metrics::from_dict(&doc, &d);
assert_eq!(m.text_of(0x61).as_deref(), Some("\u{3b1}"), "the font told us this one");
assert_eq!(m.text_of(0x62), None, "and nothing about this one");
}
#[test]
fn a_named_base_encoding_is_always_honoured() {
// Naming an encoding is a deliberate statement, so it wins even on Symbol.
for name in ["WinAnsiEncoding", "MacRomanEncoding", "StandardEncoding"] {
let mut enc = Dict::new();
enc.set(b"BaseEncoding".to_vec(), Object::name(name));
let (doc, d) = simple("Symbol", Some(4), Some(Object::Dict(enc)));
let m = Metrics::from_dict(&doc, &d);
assert_eq!(m.text_of(0x61).as_deref(), Some("a"), "{name} was asked for by name");
}
}
/// Pins a deliberate restraint, so it is not "fixed" into a regression: a great many ordinary
/// Latin TrueType subsets set the `/FontDescriptor` symbolic flag and still rely on a Latin
/// encoding. Suppressing the implicit table on the flag would lose real text on a large share
/// of real documents, so only Symbol and ZapfDingbats -- named, and unambiguous -- suppress it.
#[test]
fn the_symbolic_flag_alone_does_not_suppress_the_latin_default() {
let (doc, d) = simple("SomeSubsetFont", Some(4), None);
let m = Metrics::from_dict(&doc, &d);
assert_eq!(m.text_of(0x61).as_deref(), Some("a"));
assert_eq!(m.decode(b"abc"), "abc");
}
}

254
crates/fonts/src/std14.rs Normal file
View File

@ -0,0 +1,254 @@
//! Standard-14 font metrics (ISO 32000-2 Annex D; the Adobe Core14 AFM metrics), as advance
//! widths in glyph space — thousandths of an em.
//!
//! A PDF may name any of the fourteen standard fonts without embedding a program, and the viewer
//! has to supply the metrics. These are those metrics, exactly, replacing the character-class
//! estimate that generated appearances used before: that estimate had Times at 0.9 x Helvetica,
//! which makes a capital A 600 where the specification says 722.
//!
//! **Generated.** The tables are data, not typefaces, and are the item AGENTS.md §1.1 permits as
//! Adobe-authored non-visual technical data; `ATTRIBUTION.toml` carries the entry with
//! `adobe_data = true`. Each row is indexed by character **code**, not by Unicode: the twelve text
//! fonts are indexed by WinAnsiEncoding, which is what PdfCraft writes into the appearance streams
//! it generates, and Symbol and ZapfDingbats by their own built-in encodings. A zero means the
//! encoding leaves that code undefined.
/// One of the fourteen standard fonts (ISO 32000-2 §9.6.2.2).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Std14 {
Helvetica,
HelveticaBold,
HelveticaOblique,
HelveticaBoldOblique,
TimesRoman,
TimesBold,
TimesItalic,
TimesBoldItalic,
Courier,
CourierBold,
CourierOblique,
CourierBoldOblique,
Symbol,
ZapfDingbats,
}
/// How [`Std14::recognise`] matched a `/BaseFont` name.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Match {
/// A standard-14 face or a well-known alias of one (Arial for Helvetica, CourierNew for
/// Courier). The document is asking for this face.
Alias,
/// Only a family hint matched -- "Serif", "Roman" or "Mono" in an otherwise unknown name.
/// The face is a guess from the name, not the one the document asked for.
FamilyHint,
}
impl Std14 {
/// The font a `/BaseFont` name selects, by the spec's names and the common aliases viewers
/// accept (`Arial` for `Helvetica`, `TimesNewRoman` for `Times`, `CourierNew` for `Courier`).
/// A subset prefix such as `ABCDEF+Helvetica` is ignored.
pub fn from_base_font(base: &str) -> Option<Self> {
Self::recognise(base).map(|(face, _)| face)
}
/// [`Std14::from_base_font`], also saying *how* the name matched.
///
/// Reporting needs the difference. "Arial" really is a request for Helvetica, but
/// "SomeHouseSerif" is a house font whose name merely contains "Serif"; choosing Times for it
/// is a reasonable guess from the name, not the face the document asked for, and a substitution
/// report that called it a standard-14 name would be telling the reader something untrue.
pub fn recognise(base: &str) -> Option<(Self, Match)> {
let base = base.rsplit('+').next().unwrap_or(base);
let flat: String = base.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect();
let has = |w: &str| flat.contains(w);
let (bold, italic) = (has("bold"), has("italic") || has("oblique"));
// Two bools index a four-element array, so the subscript is provably 0..=3.
let styled = |faces: [Self; 4]| faces[usize::from(bold) | usize::from(italic) << 1];
let courier = || styled([Self::Courier, Self::CourierBold, Self::CourierOblique, Self::CourierBoldOblique]);
let times = || styled([Self::TimesRoman, Self::TimesBold, Self::TimesItalic, Self::TimesBoldItalic]);
if has("symbol") {
return Some((Self::Symbol, Match::Alias));
}
if has("zapf") || has("dingbat") {
return Some((Self::ZapfDingbats, Match::Alias));
}
if has("courier") {
return Some((courier(), Match::Alias));
}
if has("mono") {
return Some((courier(), Match::FamilyHint));
}
if has("times") {
return Some((times(), Match::Alias));
}
if has("serif") || has("roman") {
return Some((times(), Match::FamilyHint));
}
if has("helvetica") || has("arial") {
return Some((styled([Self::Helvetica, Self::HelveticaBold, Self::HelveticaOblique, Self::HelveticaBoldOblique]), Match::Alias));
}
None
}
/// The advance of one character `code`, in thousandths of an em. Zero where the font's
/// encoding leaves the code undefined.
pub fn width(self, code: u8) -> f64 {
f64::from(crate::std14_metrics::WIDTHS[self as usize][code as usize])
}
/// The advance of `code` at `size` points.
pub fn advance(self, code: u8, size: f64) -> f64 {
self.width(code) * size / 1000.0
}
/// The advance of `text` at `size` points, encoded as the twelve text fonts are written:
/// WinAnsiEncoding, with characters it cannot represent replaced as `win_ansi` replaces them.
///
/// Symbol and ZapfDingbats have their own built-in encodings and are not WinAnsi, so this
/// measures them as if they were; pass codes to [`Std14::width`] for those.
pub fn text_width(self, text: &str, size: f64) -> f64 {
crate::win_ansi(text).iter().map(|&c| self.width(c)).sum::<f64>() * size / 1000.0
}
/// Courier is the only fixed-pitch family of the fourteen.
pub fn is_fixed_pitch(self) -> bool {
matches!(self, Self::Courier | Self::CourierBold | Self::CourierOblique | Self::CourierBoldOblique)
}
/// The `/BaseFont` name for this font, as it is written into a PDF.
pub fn base_font(self) -> &'static str {
crate::std14_metrics::BASE_FONTS[self as usize]
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Widths from the published Core14 metrics, typed in here by hand so the table is checked
/// against the specification rather than against whatever produced it.
#[test]
fn widths_match_the_published_metrics() {
let cases: &[(Std14, char, u16)] = &[
// Helvetica
(Std14::Helvetica, ' ', 278),
(Std14::Helvetica, 'A', 667),
(Std14::Helvetica, 'W', 944),
(Std14::Helvetica, 'i', 222),
(Std14::Helvetica, 'm', 833),
(Std14::Helvetica, '0', 556),
(Std14::Helvetica, '@', 1015),
(Std14::HelveticaBold, 'A', 722),
(Std14::HelveticaBold, 'i', 278),
// Times: not Helvetica scaled by 0.9, which is what the old estimate assumed.
(Std14::TimesRoman, ' ', 250),
(Std14::TimesRoman, 'A', 722),
(Std14::TimesRoman, 'W', 944),
(Std14::TimesRoman, 'i', 278),
(Std14::TimesRoman, 'm', 778),
(Std14::TimesBold, 'A', 722),
(Std14::TimesItalic, 'A', 611),
// Courier is fixed pitch at 600 for every glyph.
(Std14::Courier, ' ', 600),
(Std14::Courier, 'A', 600),
(Std14::Courier, 'm', 600),
(Std14::CourierBoldOblique, 'W', 600),
];
for &(font, ch, want) in cases {
let code = crate::win_ansi(&ch.to_string());
assert_eq!(code.len(), 1, "{ch:?} is one WinAnsi byte");
assert_eq!(font.width(code[0]) as u16, want, "{font:?} {ch:?}");
}
}
#[test]
fn courier_is_fixed_pitch_everywhere_it_is_defined() {
for font in [Std14::Courier, Std14::CourierBold, Std14::CourierOblique, Std14::CourierBoldOblique] {
assert!(font.is_fixed_pitch());
for code in 0..=255u8 {
let w = font.width(code);
assert!(w == 0.0 || w == 600.0, "{font:?} code {code} has width {w}");
}
}
assert!(!Std14::Helvetica.is_fixed_pitch());
}
#[test]
fn every_text_font_covers_printable_ascii_and_latin1() {
for font in [Std14::Helvetica, Std14::TimesRoman, Std14::Courier, Std14::TimesBoldItalic] {
for code in 0x20..=0x7Eu8 {
assert!(font.width(code) > 0.0, "{font:?} has no width for ASCII {code}");
}
for code in 0xA0..=0xFFu8 {
assert!(font.width(code) > 0.0, "{font:?} has no width for Latin-1 {code}");
}
}
}
#[test]
fn symbol_and_dingbats_have_their_own_encodings() {
// Symbol's code 0x61 is alpha, not 'a'; ZapfDingbats' is a scissors glyph.
assert!(Std14::Symbol.width(0x61) > 0.0);
assert!(Std14::ZapfDingbats.width(0x61) > 0.0);
// Neither defines the Latin-1 upper half the text fonts do.
assert_eq!(Std14::Symbol.width(0xFF), 0.0);
}
#[test]
fn base_font_names_round_trip() {
for i in 0..14usize {
let font = [
Std14::Helvetica,
Std14::HelveticaBold,
Std14::HelveticaOblique,
Std14::HelveticaBoldOblique,
Std14::TimesRoman,
Std14::TimesBold,
Std14::TimesItalic,
Std14::TimesBoldItalic,
Std14::Courier,
Std14::CourierBold,
Std14::CourierOblique,
Std14::CourierBoldOblique,
Std14::Symbol,
Std14::ZapfDingbats,
][i];
assert_eq!(Std14::from_base_font(font.base_font()), Some(font), "{}", font.base_font());
}
}
#[test]
fn base_font_aliases_and_subset_prefixes_resolve() {
assert_eq!(Std14::from_base_font("ABCDEF+Helvetica-Bold"), Some(Std14::HelveticaBold));
assert_eq!(Std14::from_base_font("Arial"), Some(Std14::Helvetica));
assert_eq!(Std14::from_base_font("Arial,BoldItalic"), Some(Std14::HelveticaBoldOblique));
assert_eq!(Std14::from_base_font("TimesNewRoman,Bold"), Some(Std14::TimesBold));
assert_eq!(Std14::from_base_font("CourierNew-Oblique"), Some(Std14::CourierOblique));
assert_eq!(Std14::from_base_font("ZapfDingbats"), Some(Std14::ZapfDingbats));
assert_eq!(Std14::from_base_font("Wingdings"), None);
assert_eq!(Std14::from_base_font(""), None);
}
#[test]
fn text_width_sums_the_glyphs_and_scales_with_size() {
let h = Std14::Helvetica;
// "AW" = 667 + 944 = 1611 thousandths of an em.
assert!((h.text_width("AW", 1000.0) - 1611.0).abs() < 1e-9);
assert!((h.text_width("AW", 12.0) - 1611.0 * 12.0 / 1000.0).abs() < 1e-9);
assert_eq!(h.text_width("", 12.0), 0.0);
// Courier: every character is 600, so the width is purely a count.
assert!((Std14::Courier.text_width("hello", 10.0) - 5.0 * 6.0).abs() < 1e-9);
}
#[test]
fn hostile_text_does_not_panic_and_stays_finite() {
for font in [Std14::Helvetica, Std14::Symbol, Std14::Courier] {
for s in ["", "\0", "\u{202E}", "💥", &"a".repeat(10_000), "\u{FFFD}"] {
let w = font.text_width(s, 12.0);
assert!(w.is_finite() && w >= 0.0, "{font:?} {s:?} -> {w}");
}
assert!(font.advance(0, 12.0).is_finite());
assert!(font.advance(255, 0.0).is_finite());
}
}
}

View File

@ -0,0 +1,300 @@
//! The generated standard-14 tables: the fourteen base font names and their per-code advance
//! widths. **Data only, no logic** -- see `std14.rs` for everything that interprets it.
//!
//! This file is the closed-list item in AGENTS.md 1.1 and carries a SHA-256 in `ATTRIBUTION.toml`,
//! so `cargo xtask assets` fails until a change to it is re-attested. It is kept separate from the
//! code that reads it for exactly that reason: if ordinary edits churned the hash, re-attesting
//! would become reflexive and the check would stop meaning anything.
//!
//! Widths are in thousandths of an em, indexed by character code in each face's own encoding --
//! WinAnsi for the twelve text faces, and their built-in encodings for Symbol and ZapfDingbats.
//! A zero means the encoding leaves that code undefined. Verified against the published
//! specification by the tests in `std14.rs`, which type the expected values in by hand.
pub(crate) static BASE_FONTS: [&str; 14] = [
"Helvetica",
"Helvetica-Bold",
"Helvetica-Oblique",
"Helvetica-BoldOblique",
"Times-Roman",
"Times-Bold",
"Times-Italic",
"Times-BoldItalic",
"Courier",
"Courier-Bold",
"Courier-Oblique",
"Courier-BoldOblique",
"Symbol",
"ZapfDingbats",
];
/// Advance widths by code, one row per [`Std14`] variant in declaration order.
#[rustfmt::skip]
pub(crate) static WIDTHS: [[u16; 256]; 14] = [
// Helvetica
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0,
556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667,
278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611,
667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278,
556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500,
],
// HelveticaBold
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0,
556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667,
278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611,
722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278,
611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556,
],
// HelveticaOblique
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0,
556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667,
278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611,
667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278,
556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500,
],
// HelveticaBoldOblique
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0,
556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667,
278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333,
400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611,
722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278,
611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556,
],
// TimesRoman
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444,
921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722,
556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500,
333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500,
500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541, 0,
500, 0, 333, 500, 444, 1000, 500, 500, 333, 1000, 556, 333, 889, 0, 611, 0,
0, 333, 333, 444, 444, 350, 500, 1000, 333, 980, 389, 333, 722, 0, 444, 722,
250, 333, 500, 500, 500, 500, 200, 500, 333, 760, 276, 500, 564, 333, 760, 333,
400, 564, 300, 300, 333, 500, 453, 250, 333, 300, 310, 500, 750, 750, 750, 444,
722, 722, 722, 722, 722, 722, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333,
722, 722, 722, 722, 722, 722, 722, 564, 722, 722, 722, 722, 722, 722, 556, 500,
444, 444, 444, 444, 444, 444, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278,
500, 500, 500, 500, 500, 500, 500, 564, 500, 500, 500, 500, 500, 500, 500, 500,
],
// TimesBold
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 333, 555, 500, 500, 1000, 833, 278, 333, 333, 500, 570, 250, 333, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500,
930, 722, 667, 722, 722, 667, 611, 778, 778, 389, 500, 778, 667, 944, 722, 778,
611, 778, 722, 556, 667, 722, 722, 1000, 722, 722, 667, 333, 278, 333, 581, 500,
333, 500, 556, 444, 556, 444, 333, 500, 556, 278, 333, 556, 278, 833, 556, 500,
556, 556, 444, 389, 333, 556, 500, 722, 500, 500, 444, 394, 220, 394, 520, 0,
500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 1000, 0, 667, 0,
0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 444, 722,
250, 333, 500, 500, 500, 500, 220, 500, 333, 747, 300, 500, 570, 333, 747, 333,
400, 570, 300, 300, 333, 556, 540, 250, 333, 300, 330, 500, 750, 750, 750, 500,
722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 389, 389, 389, 389,
722, 722, 778, 778, 778, 778, 778, 570, 778, 722, 722, 722, 722, 722, 611, 556,
500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278,
500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 500, 556, 500,
],
// TimesItalic
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 333, 420, 500, 500, 833, 778, 214, 333, 333, 500, 675, 250, 333, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 675, 675, 675, 500,
920, 611, 611, 667, 722, 611, 611, 722, 722, 333, 444, 667, 556, 833, 667, 722,
611, 722, 611, 500, 556, 722, 611, 833, 611, 556, 556, 389, 278, 389, 422, 500,
333, 500, 500, 444, 500, 444, 278, 500, 500, 278, 278, 444, 278, 722, 500, 500,
500, 500, 389, 389, 278, 500, 444, 667, 444, 444, 389, 400, 275, 400, 541, 0,
500, 0, 333, 500, 556, 889, 500, 500, 333, 1000, 500, 333, 944, 0, 556, 0,
0, 333, 333, 556, 556, 350, 500, 889, 333, 980, 389, 333, 667, 0, 389, 556,
250, 389, 500, 500, 500, 500, 275, 500, 333, 760, 276, 500, 675, 333, 760, 333,
400, 675, 300, 300, 333, 500, 523, 250, 333, 300, 310, 500, 750, 750, 750, 500,
611, 611, 611, 611, 611, 611, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333,
722, 667, 722, 722, 722, 722, 722, 675, 722, 722, 722, 722, 722, 556, 611, 500,
500, 500, 500, 500, 500, 500, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278,
500, 500, 500, 500, 500, 500, 500, 675, 500, 500, 500, 500, 500, 444, 500, 444,
],
// TimesBoldItalic
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 389, 555, 500, 500, 833, 778, 278, 333, 333, 500, 570, 250, 333, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500,
832, 667, 667, 667, 722, 667, 667, 722, 778, 389, 500, 667, 611, 889, 722, 722,
611, 722, 667, 556, 611, 722, 667, 889, 667, 611, 611, 333, 278, 333, 570, 500,
333, 500, 500, 444, 500, 444, 333, 500, 556, 278, 278, 500, 278, 778, 556, 500,
500, 500, 389, 389, 278, 556, 444, 667, 500, 444, 389, 348, 220, 348, 570, 0,
500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 944, 0, 611, 0,
0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 389, 611,
250, 389, 500, 500, 500, 500, 220, 500, 333, 747, 266, 500, 606, 333, 747, 333,
400, 570, 300, 300, 333, 576, 500, 250, 333, 300, 300, 500, 750, 750, 750, 500,
667, 667, 667, 667, 667, 667, 944, 667, 667, 667, 667, 667, 389, 389, 389, 389,
722, 722, 722, 722, 722, 722, 722, 570, 722, 722, 722, 722, 722, 611, 611, 500,
500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278,
500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 444, 500, 444,
],
// Courier
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
],
// CourierBold
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
],
// CourierOblique
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
],
// CourierBoldOblique
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0,
600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0,
0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600,
],
// Symbol
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
250, 333, 713, 500, 549, 833, 778, 439, 333, 333, 500, 549, 250, 549, 250, 278,
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 549, 549, 549, 444,
549, 722, 667, 722, 612, 611, 763, 603, 722, 333, 631, 722, 686, 889, 722, 722,
768, 741, 556, 592, 611, 690, 439, 768, 645, 795, 611, 333, 863, 333, 658, 500,
500, 631, 549, 549, 494, 439, 521, 411, 603, 329, 603, 549, 549, 576, 521, 549,
549, 521, 549, 603, 439, 576, 713, 686, 493, 686, 494, 480, 200, 480, 549, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
750, 620, 247, 549, 167, 713, 500, 753, 753, 753, 753, 1042, 987, 603, 987, 603,
400, 549, 411, 549, 549, 713, 494, 460, 549, 549, 549, 549, 1000, 603, 1000, 658,
823, 686, 795, 987, 768, 768, 823, 768, 768, 713, 713, 713, 713, 713, 713, 713,
768, 713, 790, 790, 890, 823, 549, 250, 713, 603, 603, 1042, 987, 603, 987, 603,
494, 329, 790, 790, 786, 713, 384, 384, 384, 384, 384, 384, 494, 494, 494, 494,
0, 329, 274, 686, 686, 686, 384, 384, 384, 384, 384, 384, 494, 494, 494, 0,
],
// ZapfDingbats
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
278, 974, 961, 974, 980, 719, 789, 790, 791, 690, 960, 939, 549, 855, 911, 933,
911, 945, 974, 755, 846, 762, 761, 571, 677, 763, 760, 759, 754, 494, 552, 537,
577, 692, 786, 788, 788, 790, 793, 794, 816, 823, 789, 841, 823, 833, 816, 831,
923, 744, 723, 749, 790, 792, 695, 776, 768, 792, 759, 707, 708, 682, 701, 826,
815, 789, 789, 707, 687, 696, 689, 786, 787, 713, 791, 785, 791, 873, 761, 762,
762, 759, 759, 892, 892, 788, 784, 438, 138, 277, 415, 392, 392, 668, 668, 0,
390, 390, 317, 317, 276, 276, 509, 509, 410, 410, 234, 234, 334, 334, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 732, 544, 544, 910, 667, 760, 760, 776, 595, 694, 626, 788, 788, 788, 788,
788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788,
788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788,
788, 788, 788, 788, 894, 838, 1016, 458, 748, 924, 748, 918, 927, 928, 928, 834,
873, 828, 924, 924, 917, 930, 931, 463, 883, 836, 836, 867, 867, 696, 696, 874,
0, 874, 760, 946, 771, 865, 771, 888, 967, 888, 831, 873, 927, 970, 918, 0,
],
];

View File

@ -16,8 +16,7 @@ pub struct Word {
pub rect: [f64; 4],
}
/// Shapes drawn on the page, in user space: rectangles (`re`) and straight horizontal
/// segments.
/// Shapes drawn on the page, in user space: axis-aligned rectangle paths and horizontal segments.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Shapes {
pub boxes: Vec<[f64; 4]>,
@ -40,6 +39,60 @@ pub struct Candidate {
}
/// The rectangles and horizontal segments a page draws (stroked or filled), in user space.
#[derive(Default)]
struct DetectionPath {
points: Vec<[f64; 2]>,
rules: Vec<[f64; 4]>,
complex: bool,
closed: bool,
}
impl DetectionPath {
fn finish(&mut self, paths: &mut Vec<Self>) {
let path = std::mem::take(self);
if !path.rules.is_empty() || (!path.complex && path.points.len() >= 4) {
paths.push(path);
}
}
fn rectangle(&self, implicitly_closed: bool) -> Option<[f64; 4]> {
if self.complex {
return None;
}
if self.points.iter().flatten().any(|v| !v.is_finite()) {
return None;
}
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
let mut points = self.points.as_slice();
let repeated_end = points.len() > 1 && near(points[0], *points.last()?);
if repeated_end {
points = &points[..points.len() - 1];
}
if points.len() != 4 || !(self.closed || implicitly_closed || repeated_end) {
return None;
}
let mut horizontal = [false; 4];
for i in 0..4 {
let (a, b) = (points[i], points[(i + 1) % 4]);
let h = (a[1] - b[1]).abs() < 1e-6;
let v = (a[0] - b[0]).abs() < 1e-6;
if h == v {
return None; // Diagonal or zero-length edge.
}
horizontal[i] = h;
}
if (0..4).any(|i| horizontal[i] == horizontal[(i + 1) % 4]) {
return None;
}
Some([
points.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min),
points.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min),
points.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max),
points.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max),
])
}
}
pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
let mut out = Shapes::default();
let Some(p) = pdfcraft_model::pages(doc).into_iter().nth(page) else { return out };
@ -61,7 +114,8 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
let mut ctm = Matrix::IDENTITY;
let mut stack = Vec::new();
let mut path_boxes: Vec<[f64; 4]> = Vec::new();
let mut path_rules: Vec<[f64; 4]> = Vec::new();
let mut subpaths: Vec<DetectionPath> = Vec::new();
let mut path = DetectionPath::default();
let mut cur: Option<[f64; 2]> = None;
for op in parse(&data).ops {
match op.op.as_slice() {
@ -73,27 +127,75 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
}
}
b"re" => {
path.finish(&mut subpaths);
if let Some([x, y, w, h]) = op.nums::<4>() {
path_boxes.push(ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]));
let (x, y) = ctm.apply(x, y);
cur = Some([x, y]);
path.complex = true;
path.points.push([x, y]);
}
}
b"m" => {
path.finish(&mut subpaths);
cur = op.nums::<2>().map(|[x, y]| {
let (a, b) = ctm.apply(x, y);
[a, b]
})
});
if let Some(p) = cur {
path.points.push(p);
}
}
b"l" => {
if let (Some(a), Some([x, y])) = (cur, op.nums::<2>()) {
// Continuing after closepath is not a single four-edge rectangle.
if path.closed {
path.complex = true;
}
let (bx, by) = ctm.apply(x, y);
let b = [bx, by];
if !path.complex {
if path.points.len() >= 5 {
path.complex = true;
} else {
if path.points.is_empty() {
path.points.push(a);
}
path.points.push(b);
}
}
if (a[1] - b[1]).abs() < 0.5 && (a[0] - b[0]).abs() > 1.0 {
path_rules.push([a[0].min(b[0]), a[1], a[0].max(b[0]), a[1]]);
path.rules.push([a[0].min(b[0]), a[1], a[0].max(b[0]), a[1]]);
}
cur = Some(b);
}
}
b"c" | b"v" | b"y" => {
path.complex = true;
let end = if op.op.as_slice() == b"c" { op.nums::<6>().map(|v| [v[4], v[5]]) } else { op.nums::<4>().map(|v| [v[2], v[3]]) };
if let Some([x, y]) = end {
let (x, y) = ctm.apply(x, y);
cur = Some([x, y]);
}
}
b"h" => {
path.closed = true;
cur = path.points.first().copied().or(cur);
}
b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
if matches!(op.op.as_slice(), b"s" | b"b" | b"b*") {
path.closed = true;
}
path.finish(&mut subpaths);
let filled = !matches!(op.op.as_slice(), b"S" | b"s");
let mut path_rules = Vec::new();
for subpath in subpaths.drain(..) {
if let Some(rect) = subpath.rectangle(filled) {
path_boxes.push(rect);
} else {
path_rules.extend(subpath.rules);
}
}
for r in path_boxes.drain(..) {
// A very flat filled rectangle is a rule.
if r[3] - r[1] < 1.5 && r[2] - r[0] > 1.0 {
@ -107,7 +209,8 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
}
b"n" => {
path_boxes.clear();
path_rules.clear();
subpaths.clear();
path = DetectionPath::default();
cur = None;
}
_ => {}

View File

@ -1865,3 +1865,81 @@ fn invalid_appearance_patches_are_refused_before_anything_is_written() {
assert!(set_props(&mut doc, "name", &props).is_err());
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
}
#[test]
fn page_shapes_preserves_closed_path_rectangle_height() {
let shapes = |content: &[u8]| {
let mut doc = fixture();
let page = pdfcraft_model::pages(&doc)[0].obj;
let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), content)));
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
crate::detect::page_shapes(&doc, 0)
};
let shorthand = shapes(b"50 100 120 40 re S");
let outlined = shapes(b"50 100 m 170 100 l 170 140 l 50 140 l h S");
assert_eq!(outlined, shorthand, "equivalent rectangle encodings must have equal detection geometry");
let fields = crate::detect::detect(&[], &outlined, &[], &[]);
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].rect, [51.0, 101.0, 169.0, 139.0]);
}
fn detection_test_shapes(content: &[u8]) -> crate::detect::Shapes {
let mut doc = fixture();
let page = pdfcraft_model::pages(&doc)[0].obj;
let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), content)));
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
crate::detect::page_shapes(&doc, 0)
}
#[test]
fn page_shapes_accepts_rectangle_closure_and_paint_variants() {
for end in ["h S", "s", "50 100 l S", "f", "F", "f*", "B", "B*", "b", "b*"] {
let content = format!("50 100 m 170 100 l 170 140 l 50 140 l {end}");
let shapes = detection_test_shapes(content.as_bytes());
assert_eq!(shapes.boxes, [[50.0, 100.0, 170.0, 140.0]], "{end}");
assert!(shapes.rules.is_empty(), "rectangle edges must not become extra fields: {end}");
}
}
#[test]
fn page_shapes_rejects_open_diagonal_curved_and_complex_rectangles() {
for content in [
"50 100 m 170 100 l 170 140 l 50 140 l S",
"50 100 m 170 110 l 170 150 l 50 140 l h S",
"50 100 m 170 100 l 170 140 l 100 150 80 150 50 140 c h S",
"50 100 m 170 100 l 170 140 l h 50 140 l f",
"50 100 m 170 100 l 50 100 l 50 140 l h S",
] {
assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}");
}
}
#[test]
fn page_shapes_preserves_multiple_subpaths_and_discards_unpainted_paths() {
let shapes = detection_test_shapes(b"50 100 120 40 re 200 100 m 320 100 l 320 140 l 200 140 l h 50 400 m 170 400 l S");
assert_eq!(shapes.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]);
assert_eq!(shapes.rules, [[50.0, 400.0, 170.0, 400.0]]);
assert_eq!(detection_test_shapes(b"50 100 m 170 100 l 170 140 l 50 140 l h W n"), crate::detect::Shapes::default());
let continued = detection_test_shapes(b"50 100 120 40 re 170 100 l 170 140 l 50 140 l h S");
assert_eq!(continued.boxes, [[50.0, 100.0, 170.0, 140.0]], "continuing re must not invent a second box");
}
#[test]
fn page_shapes_transforms_path_rectangles_and_preserves_rules() {
let scaled = detection_test_shapes(b"q 2 0 0 2 0 0 cm 50 100 m 170 100 l 170 140 l 50 140 l h S Q 10 20 m 100 20 l S");
assert_eq!(scaled.boxes, [[100.0, 200.0, 340.0, 280.0]]);
assert_eq!(scaled.rules, [[10.0, 20.0, 100.0, 20.0]]);
let rotated = detection_test_shapes(b"0 1 -1 0 300 0 cm 50 100 m 170 100 l 170 140 l 50 140 l h S");
assert_eq!(rotated.boxes, [[160.0, 50.0, 200.0, 170.0]]);
}
#[test]
fn page_shapes_closes_only_last_stroked_subpath_but_all_filled_subpaths() {
let paths = "50 100 m 170 100 l 170 140 l 50 140 l 200 100 m 320 100 l 320 140 l 200 140 l";
let stroked = detection_test_shapes(format!("{paths} s").as_bytes());
assert_eq!(stroked.boxes, [[200.0, 100.0, 320.0, 140.0]]);
assert_eq!(stroked.rules, [[50.0, 100.0, 170.0, 100.0], [50.0, 140.0, 170.0, 140.0]]);
let filled = detection_test_shapes(format!("{paths} f").as_bytes());
assert_eq!(filled.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]);
assert!(filled.rules.is_empty());
}

View File

@ -1037,6 +1037,26 @@ mod session {
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
}
#[test]
fn a_scripts_time_starts_when_its_context_is_ready() {
// The engine made slow to be ready (300 ms) under a 50 ms script limit: the script is
// not abandoned, its time starting only when the engine says it is ready.
use crate::xfa_session::START_DELAY_MS;
use std::sync::atomic::Ordering::Relaxed;
let mut s = XfaSession::new(XfaDoc::default());
START_DELAY_MS.store(300, Relaxed);
let mut quick = script("1", false, "form1[0].page1[0].go[0]", "click");
quick.timeout = std::time::Duration::from_millis(50);
let js = s.run(quick);
let mut quick = script("1", true, "form1[0].page1[0].go[0]", "click");
quick.timeout = std::time::Duration::from_millis(50);
let fc = s.run(quick);
START_DELAY_MS.store(0, Relaxed);
assert!(!js.abandoned && js.result.as_deref() == Some("1"), "{js:?}");
assert!(!fc.abandoned && fc.result.as_deref() == Some("1"), "{fc:?}");
assert_eq!(s.dead(), None);
}
#[test]
fn a_script_past_its_time_is_abandoned_and_the_session_is_dead_after_it() {
let mut s = XfaSession::new(XfaDoc::default());

View File

@ -1458,7 +1458,6 @@ pub(crate) fn warm_up() {
pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome {
warm_up();
let mut ctx = Context::default();
started();
ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations);
ctx.runtime_limits_mut().set_recursion_limit(limits.recursion);
let host_state = XHost::new(root, &event.target, doc);
@ -1467,6 +1466,8 @@ pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNo
let mut out = XfaOutcome::default();
let result = (|| -> JsResult<()> {
install(&mut ctx, event, doc, current)?;
// Ready for the script: its time starts now, the set-up above not charged to it.
started();
ctx.register_global_property(js_string!("__xfa_src"), s(script), Attribute::CONFIGURABLE)?;
// Direct eval inside a function called on the node: `this` is the node and the
// completion value is the script's last expression (what a calculate script yields).

View File

@ -13,6 +13,10 @@
use crate::Limits;
use crate::xfa::{XfaDoc, XfaEvent, XfaNode, XfaOutcome};
/// Tests: milliseconds the engine is made to take before it is ready for a script.
#[cfg(test)]
pub(crate) static START_DELAY_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// What one script run needs.
#[derive(Clone, Debug)]
pub struct Script {
@ -25,15 +29,29 @@ pub struct Script {
pub timeout: std::time::Duration,
}
/// Run `s` here: a fresh context (JavaScript) or interpreter (FormCalc) on its copy of the form.
fn run_script(s: Script, doc: &XfaDoc) -> XfaOutcome {
/// Run `s` here: a fresh context (JavaScript) or interpreter (FormCalc) on its copy of the
/// form. `started` is called once the engine is ready for the script (its context built), so
/// a time limit on the script runs from then, not from when it was asked for.
fn run_script(s: Script, doc: &XfaDoc, started: &dyn Fn()) -> XfaOutcome {
// Tests make the engine slow to be ready, to see that the time isn't the script's.
#[cfg(test)]
let started = &|| {
let ms = START_DELAY_MS.load(std::sync::atomic::Ordering::Relaxed);
if ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(ms));
}
started();
};
if s.formcalc {
// FormCalc has no engine to start: its time (and its own deadline) begins at once.
started();
return crate::formcalc::run_formcalc_at(&s.source, &s.event, doc, s.root, s.limits, Some(s.timeout));
}
if let Some(why) = crate::refuse(&s.source) {
started();
return XfaOutcome { error: Some(why), ..Default::default() };
}
crate::xfa::run_here(&s.source, &s.event, doc, s.root, s.limits, &|| {})
crate::xfa::run_here(&s.source, &s.event, doc, s.root, s.limits, started)
}
#[cfg(not(target_arch = "wasm32"))]
@ -45,7 +63,10 @@ mod native {
use crate::xfa::{ENGINE_START_LIMIT, XfaDoc, XfaOutcome};
enum Reply {
/// The thread is up.
Ready,
/// The engine is ready for the script just sent: its time starts now.
Started,
Outcome(Box<XfaOutcome>),
}
@ -85,7 +106,10 @@ mod native {
// The caller is gone when a send fails: nothing to report then.
let _ = reply.send(Reply::Ready);
while let Ok(s) = inbox.recv() {
let o = run_script(*s, &doc);
let started = reply.clone();
let o = run_script(*s, &doc, &move || {
let _ = started.send(Reply::Started);
});
let _ = reply.send(Reply::Outcome(Box::new(o)));
}
});
@ -103,8 +127,9 @@ mod native {
self.worker.as_mut().ok_or_else(|| "the script engine is not running".into())
}
/// Run one script, waiting at most its `timeout`; past that it is abandoned on its
/// thread and the session is dead.
/// Run one script, waiting at most its `timeout` from when the engine is ready for it
/// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready); past that it is
/// abandoned on its thread and the session is dead.
pub fn run(&mut self, script: Script) -> XfaOutcome {
let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() };
let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() };
@ -121,15 +146,28 @@ mod native {
self.worker = None;
return failed("the script engine stopped with an internal error".into());
}
let deadline = Instant::now().checked_add(timeout);
// The engine's start, then the script's own time.
let (mut running, mut limit) = (false, ENGINE_START_LIMIT);
let mut deadline = Instant::now().checked_add(limit);
loop {
let left = deadline.map_or(timeout, |d| d.saturating_duration_since(Instant::now()));
let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now()));
match worker.from.recv_timeout(left) {
Ok(Reply::Outcome(o)) => return *o,
Ok(Reply::Started) => {
// A second call changes nothing: the script's time has already begun.
if !running {
(running, limit) = (true, timeout);
deadline = Instant::now().checked_add(limit);
}
}
// Not expected here: wait on for the outcome.
Ok(Reply::Ready) => continue,
Err(RecvTimeoutError::Timeout) => {
let why = format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64());
let why = if running {
format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())
} else {
format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64())
};
self.dead = Some(why.clone());
// Dropping the sender ends the thread's loop once the script is done.
self.worker = None;
@ -165,7 +203,7 @@ mod native {
}
pub fn run(&mut self, script: Script) -> XfaOutcome {
run_script(script, &self.doc)
run_script(script, &self.doc, &|| {})
}
}
}

View File

@ -300,9 +300,10 @@ fn import_pages_mapped(
ocgs: Vec::new(),
});
let src_dict = src.get(page).as_dict().cloned().unwrap_or_default();
let resources = resources_with_ancestors(src, &src_dict);
let mut d = Dict::new();
for (k, v) in src_dict.iter() {
if SKIP_ON_PAGE.contains(&k.as_slice()) {
if SKIP_ON_PAGE.contains(&k.as_slice()) || (k.as_slice() == b"Resources" && resources.is_some()) {
continue;
}
if k.as_slice() == b"Annots" {
@ -364,6 +365,10 @@ fn import_pages_mapped(
}
d.set(k.clone(), copier.copy_value(dst, v, None));
}
if let Some(r) = resources {
let r = copier.copy_value(dst, &r, None);
d.set(b"Resources".to_vec(), r);
}
for k in INHERITABLE {
if !d.contains(k)
&& let Some(v) = inherited.get(k)
@ -398,6 +403,56 @@ fn import_pages_mapped(
Ok(Imported { pages: new_pages, page_map: copier.pages, objects: copier.map, renamed_fields })
}
/// A page's `/Resources` with the names that only its page-tree ancestors list, or `None` when
/// they add nothing to the dictionary it would have anyway (its own, or else its nearest
/// ancestor's). Readers (ours included) look a resource name up the page and then up the tree,
/// so a page that lists its font but inherits its images still shows them. A copy hangs from
/// another tree, so it must list them itself or it draws nothing of them (#1628). The page's
/// entry wins over an ancestor's, and a nearer ancestor's over a farther one's.
fn resources_with_ancestors(src: &Document, page: &Dict) -> Option<Object> {
let mut merged = page.get(b"Resources").map(|r| src.dict(r).unwrap_or_default());
let mut added = false;
let mut seen = std::collections::HashSet::new();
let mut parent = page.reference(b"Parent");
// Bounded: a damaged tree's `/Parent` links can loop or run deep.
while let Some(r) = parent
&& seen.insert(r)
&& seen.len() <= 256
{
let node = src.get(r);
let Some(node) = node.as_dict() else { break };
parent = node.reference(b"Parent");
let Some(theirs) = node.get(b"Resources").and_then(|r| src.dict(r)) else { continue };
let Some(mine) = merged.as_mut() else {
merged = Some(theirs);
continue;
};
for (category, entries) in theirs.iter() {
match (mine.get(category).map(|c| src.dict(c)), src.dict(entries)) {
(None, _) => {
mine.set(category.clone(), entries.clone());
added = true;
}
(Some(Some(mut names)), Some(entries)) => {
let before = names.len();
for (name, value) in entries.iter() {
if !names.contains(name) {
names.set(name.clone(), value.clone());
}
}
if names.len() > before {
mine.set(category.clone(), Object::Dict(names));
added = true;
}
}
// `/ProcSet` and the like: the page's own value stands.
_ => {}
}
}
}
merged.filter(|_| added).map(Object::Dict)
}
/// Look up a named destination in the source (`/Dests` dictionary or `/Names /Dests` tree).
fn named_dest(src: &Document, name: &[u8]) -> Option<Object> {
let catalog = src.get(src.root()?).as_dict().cloned()?;

View File

@ -21,6 +21,7 @@ mod outline;
mod pdfx;
mod prune;
pub mod view;
mod xmp;
pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box};
pub use dedupe::dedupe_resources;
@ -40,7 +41,8 @@ pub use view::{InitialView, displays_doc_title, initial_view, set_initial_view};
pub enum OrganizeError {
#[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("a document must keep at least one page")]
WouldRemoveAllPages,
@ -158,11 +160,11 @@ pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Resu
}
let all = walk(doc)?;
check(indices, all.len())?;
let delta = (degrees / 90) * 90;
let delta = degrees.rem_euclid(360);
for &i in indices {
let (page, inherited) = &all[i];
let current = doc.get(*page).as_dict().and_then(|d| d.int(b"Rotate")).or_else(|| inherited.int(b"Rotate")).unwrap_or(0);
let new = (current + delta).rem_euclid(360);
let new = (current.rem_euclid(360) + delta).rem_euclid(360);
doc.update_dict(*page, |d| d.set(b"Rotate".to_vec(), Object::Int(new)))?;
}
Ok(())
@ -503,12 +505,14 @@ pub fn info(doc: &Document, key: &str) -> Option<String> {
/// Set (or clear, with an empty value) a document-information entry.
///
/// Note: documents with XMP metadata also carry these values in the XMP packet; synchronising
/// XMP arrives with the `model` crate (M2) — viewers that prefer XMP may still show old values.
/// Documents with XMP metadata also carry Title, Author, Subject, Keywords, Creator and Producer
/// in the XMP packet, and viewers that read XMP first show its value: the packet's property is
/// changed (or removed) to match (see `xmp`).
pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> {
let value = value.trim();
let entry = (!value.is_empty()).then(|| Object::String(PdfString::text(value)));
set_info_entry(doc, key.as_bytes(), entry)
set_info_entry(doc, key.as_bytes(), entry)?;
xmp::sync_info(doc, key, value)
}
/// Set (or remove, with `None`) a document-information entry of any type.

View File

@ -191,7 +191,7 @@ fn unescape(s: &str) -> String {
}
/// `s` as XML text: markup escaped, and characters XML 1.0 does not allow left out.
fn esc(s: &str) -> String {
pub(crate) fn esc(s: &str) -> String {
s.chars()
.filter(|c| (!c.is_control() || matches!(c, '\t' | '\n' | '\r')) && !matches!(c, '\u{FFFE}' | '\u{FFFF}'))
.collect::<String>()

View File

@ -14,6 +14,8 @@ use crate::OrganizeError;
/// Form XObjects nested deeper than this are a broken file, not a reason to hang.
const MAX_FORM_DEPTH: u8 = 8;
/// Count every resource object and direct child; a graph beyond this is left unpruned.
const MAX_GLYPH_RESOURCE_WORK: usize = 10_000;
/// The dictionary a set of drawn names belongs to.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
@ -42,6 +44,12 @@ struct Pruner<'a> {
/// past the depth limit, Type 3 glyphs drawing through them). They are left untouched:
/// pruning must never drop an XObject something still draws.
unsure: HashSet<XKey>,
/// An overlarge glyph-resource graph is not safe to prune at all.
glyph_budget_exhausted: bool,
glyph_work_left: usize,
glyph_seen: HashSet<ObjRef>,
/// An indirect font's explicit resources have one owner regardless of which page uses it.
glyph_fonts: HashSet<ObjRef>,
}
impl Pruner<'_> {
@ -52,14 +60,106 @@ impl Pruner<'_> {
}
}
/// True when `resources` lists a Type 3 font without its own `/Resources`: its glyph
/// procedures draw through these resources, and they are not followed here.
fn has_inheriting_type3(&self, resources: &Dict) -> bool {
let Some(fonts) = resources.get(b"Font").map(|f| self.doc.resolve(f)) else { return false };
let Some(fonts) = fonts.as_dict() else { return false };
fonts
.iter()
.any(|(_, f)| self.doc.resolve(f).as_dict().is_some_and(|fd| fd.name(b"Subtype") == Some(b"Type3") && fd.get(b"Resources").is_none()))
fn glyph_work(&mut self, count: usize) -> bool {
if !self.glyph_budget_exhausted
&& let Some(left) = self.glyph_work_left.checked_sub(count)
{
self.glyph_work_left = left;
return true;
}
self.glyph_budget_exhausted = true;
false
}
/// Walk one object's direct children without cloning them or recursing. Referenced objects
/// go on a separate work list, so their borrowed dictionaries stay alive during this pass.
fn protect_glyph_tree(&mut self, object: &Object, referenced: &mut Vec<ObjRef>) {
if !self.glyph_work(1) {
return;
}
let mut pending = vec![object];
while let Some(object) = pending.pop() {
match object {
Object::Ref(r) => {
// A glyph's forms, patterns and soft masks can share either the XObject
// dictionary itself or a resource owner with content we do interpret.
self.unsure.insert(XKey::XObject(*r));
self.unsure.insert(XKey::Resources(*r));
self.unsure.insert(XKey::Page(*r));
if self.glyph_seen.insert(*r) {
referenced.push(*r);
}
}
Object::Array(array) => {
if !self.glyph_work(array.len()) {
return;
}
pending.extend(array);
}
Object::Dict(_) | Object::Stream(_) => {
if let Some(dict) = object.as_dict() {
if !self.glyph_work(dict.len()) {
return;
}
pending.extend(dict.iter().map(|(_, value)| value));
}
}
_ => {}
}
}
}
/// Glyph programs are not interpreted here. Keep every resource owner reachable from their
/// resource graph, including nested forms, patterns and soft masks. Data bytes are never
/// decoded; references are visited once and all direct children count against the budget.
fn protect_glyph_object(&mut self, object: &Object) {
let mut referenced = Vec::new();
self.protect_glyph_tree(object, &mut referenced);
while !self.glyph_budget_exhausted {
let Some(r) = referenced.pop() else { break };
let object = self.doc.get(r);
self.protect_glyph_tree(&object, &mut referenced);
}
}
fn protect_type3_resources(&mut self, resources: &Dict, owner: Owner) {
if self.glyph_budget_exhausted {
return;
}
let Some(fonts) = resources.get(b"Font").map(|f| self.doc.resolve(f)) else { return };
let Some(fonts) = fonts.as_dict() else { return };
// Bound discovery too, but don't spend the glyph-graph budget repeatedly examining an
// ordinary shared font catalog on every page of a large document.
if fonts.len() > MAX_GLYPH_RESOURCE_WORK {
self.glyph_budget_exhausted = true;
return;
}
for (_, font) in fonts.iter() {
if self.glyph_budget_exhausted {
return;
}
let font_ref = font.as_ref();
let resolved = self.doc.resolve(font);
let Some(font) = resolved.as_dict().filter(|f| f.name(b"Subtype") == Some(b"Type3")) else { continue };
if let Some(glyph_resources) = font.get(b"Resources") {
if let Some(r) = font_ref
&& !self.glyph_fonts.insert(r)
{
continue;
}
self.protect_glyph_object(glyph_resources);
} else {
self.distrust(resources, owner);
// This root dictionary is already owned by the content caller. Only its
// referenced descendants can share additional owners elsewhere in the file.
for (_, value) in resources.iter() {
self.protect_glyph_object(value);
if self.glyph_budget_exhausted {
return;
}
}
}
}
}
/// The `/XObject` dictionary of `resources`, with the key its drawn names belong to.
@ -77,10 +177,11 @@ impl Pruner<'_> {
/// Record every XObject name `data` draws with `Do`, descending into form XObjects.
fn content(&mut self, data: &[u8], resources: &Dict, owner: Owner, depth: u8) {
self.protect_type3_resources(resources, owner);
let Some((xobjects, key)) = self.xobjects(resources, owner) else {
return;
};
if depth > MAX_FORM_DEPTH || self.has_inheriting_type3(resources) {
if depth > MAX_FORM_DEPTH {
self.unsure.insert(key);
return;
}
@ -113,7 +214,10 @@ impl Pruner<'_> {
match s.decoded() {
Ok(data) => self.content(&data, &inner, inner_owner, depth + 1),
// What the form draws is unknown: keep everything it could reach.
Err(_) => self.distrust(&inner, inner_owner),
Err(_) => {
self.protect_type3_resources(&inner, inner_owner);
self.distrust(&inner, inner_owner);
}
}
}
}
@ -202,7 +306,15 @@ fn filter_at(doc: &mut Document, key: XKey, names: &HashSet<Vec<u8>>) -> Result<
/// Drop the page-resource entries the document's pages never draw. Returns how many were removed.
pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, OrganizeError> {
let pages = crate::walk(doc)?;
let mut pruner = Pruner { doc, used: HashMap::new(), unsure: HashSet::new() };
let mut pruner = Pruner {
doc,
used: HashMap::new(),
unsure: HashSet::new(),
glyph_budget_exhausted: false,
glyph_work_left: MAX_GLYPH_RESOURCE_WORK,
glyph_seen: HashSet::new(),
glyph_fonts: HashSet::new(),
};
for (page, attrs) in &pages {
let Some(res) = attrs.get(b"Resources") else { continue };
let Some((res_dict, owner)) = resolve_resources(pruner.doc, res, *page) else { continue };
@ -211,10 +323,16 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
match content_bytes(pruner.doc, &contents) {
Some(data) if data.is_empty() => {}
Some(data) => pruner.content(&data, &res_dict, owner, 0),
None => pruner.distrust(&res_dict, owner),
None => {
pruner.protect_type3_resources(&res_dict, owner);
pruner.distrust(&res_dict, owner);
}
}
}
let Pruner { used, unsure, .. } = pruner;
let Pruner { used, unsure, glyph_budget_exhausted, .. } = pruner;
if glyph_budget_exhausted {
return Ok(0);
}
let mut removed = 0;
for (key, names) in used {
if !unsure.contains(&key) {

View File

@ -28,6 +28,20 @@ fn rotate_rejects_angles_that_are_not_multiples_of_90() {
assert_eq!(page_rotation(&doc, 0).unwrap(), 0);
}
#[test]
fn rotate_handles_large_quarter_turns() {
let large = i64::MAX - i64::MAX % 90;
for (current, degrees, expected) in [(90, large, 90), (-90, -large, 270), (large, 90, 90), (-large, -90, 270), (180, large - 90, 90)] {
for inherited in [false, true] {
let mut doc = open(fixture());
let target = if inherited { pages_root(&doc).unwrap() } else { pages(&doc).unwrap()[0].obj };
doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Int(current))).unwrap();
rotate_pages(&mut doc, &[0], degrees).unwrap();
assert_eq!(page_rotation(&doc, 0).unwrap(), expected);
}
}
}
/// A 3-page document with a nested page tree. MediaBox and Rotate are inherited from the root,
/// Resources from an intermediate node; each page's content says which page it is.
fn fixture() -> Vec<u8> {
@ -215,6 +229,7 @@ fn cannot_delete_every_page_or_missing_pages() {
assert_eq!(delete_pages(&mut doc, &[0, 1, 2]), Err(OrganizeError::WouldRemoveAllPages));
assert_eq!(delete_pages(&mut doc, &[7]), Err(OrganizeError::NoSuchPage(7)));
assert_eq!(rotate_pages(&mut doc, &[3], 90), Err(OrganizeError::NoSuchPage(3)));
assert_eq!(OrganizeError::NoSuchPage(3).to_string(), "page 4 does not exist", "the message counts pages from 1 (#1550)");
assert!(!doc.is_modified(), "failed operations leave the document untouched");
}
@ -252,6 +267,101 @@ fn document_info_edits_round_trip_with_unicode() {
assert_eq!(info(&doc, "Producer"), None);
}
/// A one-page document whose catalog has `xmp` as its (Flate-compressed) XMP packet, and the
/// same title and author in `/Info`, as LibreOffice writes them.
fn doc_with_xmp(xmp: &[u8]) -> Document {
let b = build(
&[
"<< /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 >>".into(),
body("X"),
"<< /Title (Untitled 1) /Author (Old Author) >>".into(),
],
"/Root 1 0 R /Info 5 0 R",
);
let mut doc = open(b);
let mut dict = Dict::new();
dict.set(b"Type".to_vec(), Object::name("Metadata"));
dict.set(b"Subtype".to_vec(), Object::name("XML"));
let m = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(dict, xmp)));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Metadata".to_vec(), Object::Ref(m))).unwrap();
full_roundtrip(&doc)
}
/// The catalog's metadata stream.
fn xmp_stream(doc: &Document) -> Option<pdfcraft_cos::Stream> {
match &*doc.get(catalog(doc).reference(b"Metadata")?) {
Object::Stream(s) => Some(s.clone()),
_ => None,
}
}
fn xmp_text(doc: &Document) -> Option<String> {
xmp_stream(doc).map(|s| String::from_utf8(s.decoded().unwrap()).unwrap())
}
/// The values the XMP packet gives a property: the items of its rdf:Alt or rdf:Seq, else its text.
fn xmp_values(xmp: &str, name: &str) -> Vec<String> {
let Some(start) = xmp.find(&format!("<{name}>")) else { return Vec::new() };
let end = xmp[start..].find(&format!("</{name}>")).unwrap() + start;
let inner = &xmp[start + name.len() + 2..end];
if !inner.contains("<rdf:li") {
return vec![inner.to_string()];
}
inner.split("<rdf:li").skip(1).map(|li| li[li.find('>').unwrap() + 1..li.find("</rdf:li>").unwrap()].to_string()).collect()
}
const LIBREOFFICE_XMP: &str = "<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n\
<rdf:Description rdf:about=\"\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\"><pdf:Producer>Writer 1.0</pdf:Producer></rdf:Description>\n\
<rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n\
<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">Untitled 1</rdf:li></rdf:Alt></dc:title>\n\
<dc:creator><rdf:Seq><rdf:li>Old Author</rdf:li></rdf:Seq></dc:creator>\n\
<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">Old subject</rdf:li></rdf:Alt></dc:description>\n\
</rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>";
#[test]
fn document_info_edits_update_the_xmp_packet() {
// Viewers that read XMP first (pdf.js takes dc:title) showed the old title after a change.
let mut doc = doc_with_xmp(LIBREOFFICE_XMP.as_bytes());
assert_eq!(xmp_values(&xmp_text(&doc).unwrap(), "dc:title"), ["Untitled 1"]);
set_info(&mut doc, "Title", "Sales Guide v1.0 & <draft>").unwrap();
set_info(&mut doc, "Author", "Demo Consulting").unwrap();
set_info(&mut doc, "Subject", "").unwrap();
set_info(&mut doc, "Keywords", "alpha, beta").unwrap();
set_info(&mut doc, "Rating", "5").unwrap(); // no XMP counterpart: /Info only
let doc = save_and_reopen(&doc);
assert_eq!(info(&doc, "Title").as_deref(), Some("Sales Guide v1.0 & <draft>"));
let xmp = xmp_text(&doc).unwrap();
assert_eq!(xmp_values(&xmp, "dc:title"), ["Sales Guide v1.0 &amp; &lt;draft&gt;"]);
assert_eq!(xmp_values(&xmp, "dc:creator"), ["Demo Consulting"]);
assert!(xmp_values(&xmp, "dc:description").is_empty(), "{xmp}");
assert_eq!(xmp_values(&xmp, "pdf:Keywords"), ["alpha, beta"]);
assert_eq!(xmp_values(&xmp, "pdf:Producer"), ["Writer 1.0"]);
assert!(xmp.starts_with("<?xpacket begin=") && xmp.ends_with("<?xpacket end=\"w\"?>"), "{xmp}");
// Stored unfiltered, still typed as metadata.
let dict = xmp_stream(&doc).unwrap().dict;
assert_eq!((dict.name(b"Type"), dict.name(b"Subtype"), dict.get(b"Filter")), (Some(&b"Metadata"[..]), Some(&b"XML"[..]), None));
}
#[test]
fn document_info_edits_leave_files_without_usable_xmp_alone() {
// No packet: none is added.
let mut doc = open(fixture());
set_info(&mut doc, "Title", "New").unwrap();
assert_eq!(xmp_text(&save_and_reopen(&doc)), None);
// A packet that isn't UTF-8 is not rewritten.
let mut latin1 = LIBREOFFICE_XMP.as_bytes().to_vec();
latin1.extend_from_slice(b"<!-- \xE9 -->");
let mut doc = doc_with_xmp(&latin1);
set_info(&mut doc, "Title", "New").unwrap();
let doc = save_and_reopen(&doc);
assert_eq!(info(&doc, "Title").as_deref(), Some("New"));
assert_eq!(xmp_stream(&doc).unwrap().decoded().unwrap(), latin1);
}
#[test]
fn successive_incremental_saves_stack_revisions() {
let mut doc = open(fixture());
@ -461,6 +571,40 @@ fn inherited_attributes_travel_with_copied_pages() {
assert_eq!(hay.pages().first().unwrap().render_dimensions(), (250.0, 500.0), "rotated 90°");
}
#[test]
fn resources_split_between_page_and_tree_travel_together() {
// The page names its own font but draws a form that only the page-tree node lists. Readers
// (ours included) look a name up the whole chain, so the page shows; a copy that kept only
// the page's own dictionary drew nothing (#1628).
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] /Resources << /XObject << /X0 5 0 R >> /Font << /F1 6 0 R /F2 6 0 R >> >> >>"
.into(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F2 7 0 R >> >> >>".into(),
{
let s = "q 100 0 0 100 50 50 cm /X0 Do Q BT /F2 12 Tf (x) Tj ET";
format!("<< /Length {} >>\nstream\n{s}\nendstream", s.len())
},
{
let s = "0 0 1 rg 0 0 1 1 re f";
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] /Length {} >>\nstream\n{s}\nendstream", s.len())
},
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".into(),
];
let src = open(build(&b, "/Root 1 0 R"));
let out = full_roundtrip(&combine(&[("S", &src), ("S again", &src)]).unwrap());
for i in 0..2 {
let res = out.dict(page_dict(&out, i).get(b"Resources").unwrap()).unwrap();
let entry = |cat: &[u8], name: &[u8]| res.get(cat).and_then(|c| out.dict(c)).and_then(|d| d.get(name).map(|v| out.resolve(v)));
let form = entry(b"XObject", b"X0").expect("the tree's form comes along");
assert_eq!(form.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Form"[..]));
let font = |name: &[u8]| entry(b"Font", name).and_then(|f| f.as_dict().and_then(|d| d.name(b"BaseFont").map(<[u8]>::to_vec)));
assert_eq!(font(b"F1").as_deref(), Some(&b"Helvetica"[..]), "a name only the tree has is added");
assert_eq!(font(b"F2").as_deref(), Some(&b"Courier"[..]), "the page's own entry wins over the tree's");
}
}
#[test]
fn links_are_rewired_to_copied_pages_or_dropped() {
let a = doc_a();
@ -1703,6 +1847,215 @@ fn extract_keeps_images_type3_glyphs_may_draw() {
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "pruning must not run through Type 3 resources");
}
/// The font has its own resources, but its XObject dictionary is also used by the page.
/// Looking only at page `Do` operators must not discard the image painted by a glyph.
fn doc_type3_shared_xobjects(font_resources: &str) -> Document {
let objects: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources 5 0 R >>".into(),
raw("q 30 0 0 30 10 10 cm /ImA Do Q BT /T3 50 Tf 50 80 Td (A) Tj ET"),
"<< /XObject 6 0 R /Font << /T3 7 0 R >> >>".into(),
"<< /ImA 9 0 R /ImB 10 0 R /Unused 11 0 R >>".into(),
format!(
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /CharProcs << /A 8 0 R >> /Encoding << /Type /Encoding /Differences [65 /A] >> /FirstChar 65 /LastChar 65 /Widths [1] /Resources {font_resources} >>"
),
raw("1 0 0 0 1 1 d1 /ImB Do"),
image(1, 1, 1),
image(1, 1, 1),
image(1, 1, 2000),
];
open(build(&objects, "/Root 1 0 R"))
}
#[test]
fn extract_keeps_shared_xobjects_used_by_type3_own_resources() {
for resources in ["<< /XObject 6 0 R >>", "5 0 R"] {
let src = doc_type3_shared_xobjects(resources);
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
let names = xobject_names(&part, 0);
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "the Type 3 glyph's image must survive extraction: {resources}");
}
}
#[test]
fn extract_still_prunes_page_xobjects_when_type3_resources_are_separate() {
let src = doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>");
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert_eq!(xobject_names(&part, 0), [b"ImA".to_vec()], "only the page's unused entries are removed");
let resources = part.resolve(page_dict(&part, 0).get(b"Resources").unwrap());
let fonts = part.resolve(resources.as_dict().unwrap().get(b"Font").unwrap());
let font = part.resolve(fonts.as_dict().unwrap().get(b"T3").unwrap());
let resources = part.resolve(font.as_dict().unwrap().get(b"Resources").unwrap());
let xobjects = part.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap());
assert!(xobjects.as_dict().unwrap().contains(b"ImB"), "the separate glyph resource survives the full rewrite");
}
fn doc_type3_glyph_form_with_shared_resources(cycle: bool) -> Document {
let mut doc = doc_type3_shared_xobjects("<< >>");
let mut resources = pdfcraft_cos::Dict::new();
resources.set(b"XObject".to_vec(), Object::Ref(ObjRef::new(6, 0)));
let mut form = pdfcraft_cos::Dict::new();
form.set(b"Type".to_vec(), Object::name("XObject"));
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(1), Object::Int(1)]));
form.set(b"Resources".to_vec(), Object::Dict(resources));
let form = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(form, b"/ImB Do".to_vec())));
let mut xobjects = pdfcraft_cos::Dict::new();
xobjects.set(b"Fm".to_vec(), Object::Ref(form));
let mut resources = pdfcraft_cos::Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
doc.update_dict(ObjRef::new(7, 0), |font| font.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
doc.set(ObjRef::new(8, 0), Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"1 0 0 0 1 1 d1 /Fm Do".to_vec())));
if cycle {
doc.update_dict(ObjRef::new(6, 0), |xobjects| xobjects.set(b"Again".to_vec(), Object::Ref(form))).unwrap();
}
full_roundtrip(&doc)
}
#[test]
fn extract_keeps_shared_resources_reached_through_a_type3_glyph_form() {
for cycle in [false, true] {
let src = doc_type3_glyph_form_with_shared_resources(cycle);
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "a glyph's nested form draws ImB; resource cycle: {cycle}");
}
}
#[test]
fn extract_keeps_an_indirect_resource_owner_shared_with_a_type3_font() {
let mut src = doc_type3_shared_xobjects("5 0 R");
let xobjects = src.get(ObjRef::new(6, 0)).as_dict().unwrap().clone();
src.update_dict(ObjRef::new(5, 0), |resources| resources.set(b"XObject".to_vec(), Object::Dict(xobjects))).unwrap();
let src = full_roundtrip(&src);
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "the resource owner itself is shared, with inline XObjects");
}
#[test]
fn extract_skips_pruning_when_direct_glyph_resource_children_exceed_the_budget() {
let mut src = doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>");
let font = src.get(ObjRef::new(7, 0));
let mut resources = font.as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone();
let mut unknown = vec![Object::Null; 20_000];
unknown.push(Object::Ref(ObjRef::new(6, 0)));
resources.set(b"Unknown".to_vec(), Object::Array(unknown));
src.update_dict(ObjRef::new(7, 0), |font| font.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
let src = full_roundtrip(&src);
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert_eq!(xobject_names(&part, 0), xobject_names(&src, 0), "an incomplete glyph resource walk cannot authorize pruning any owners");
}
fn doc_type3_resources_of_undecodable_content(form: bool) -> Document {
let mut doc = doc_type3_shared_xobjects("<< /XObject 6 0 R >>");
doc.update_dict(ObjRef::new(5, 0), |resources| {
resources.remove(b"Font");
})
.unwrap();
doc.set(ObjRef::new(4, 0), Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"/ImA Do".to_vec())));
let mut fonts = pdfcraft_cos::Dict::new();
fonts.set(b"T3".to_vec(), Object::Ref(ObjRef::new(7, 0)));
let mut glyph_resources = pdfcraft_cos::Dict::new();
glyph_resources.set(b"Font".to_vec(), Object::Dict(fonts));
let mut unreadable = pdfcraft_cos::Dict::new();
unreadable.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
if form {
unreadable.set(b"Type".to_vec(), Object::name("XObject"));
unreadable.set(b"Subtype".to_vec(), Object::name("Form"));
unreadable.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(200), Object::Int(200)]));
unreadable.set(b"Resources".to_vec(), Object::Dict(glyph_resources.clone()));
}
let unreadable = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(unreadable, b"BT /T3 50 Tf 50 80 Td (A) Tj ET".to_vec())));
let (contents, resources) = if form {
let contents = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"/Fm Do".to_vec())));
let mut xobjects = pdfcraft_cos::Dict::new();
xobjects.set(b"Fm".to_vec(), Object::Ref(unreadable));
let mut resources = pdfcraft_cos::Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
(contents, resources)
} else {
(unreadable, glyph_resources)
};
let mut page = pdfcraft_cos::Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Parent".to_vec(), Object::Ref(ObjRef::new(2, 0)));
page.set(b"Contents".to_vec(), Object::Ref(contents));
page.set(b"Resources".to_vec(), Object::Dict(resources));
let page = doc.add(page);
doc.update_dict(ObjRef::new(2, 0), |pages| {
pages.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(page)]));
pages.set(b"Count".to_vec(), Object::Int(2));
})
.unwrap();
full_roundtrip(&doc)
}
#[test]
fn extract_keeps_type3_glyph_resources_of_an_undecodable_page() {
let part = full_roundtrip(&extract_pages(&doc_type3_resources_of_undecodable_content(false), &[0, 1]).unwrap());
assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "an unreadable page's font shares a different page's XObject dictionary");
let contents = part.resolve(page_dict(&part, 1).get(b"Contents").unwrap());
let Object::Stream(contents) = &*contents else { panic!("content stream") };
assert_eq!(contents.dict.name(b"Filter"), Some(b"NoSuchFilter".as_slice()));
assert_eq!(contents.raw.as_ref(), b"BT /T3 50 Tf 50 80 Td (A) Tj ET");
}
#[test]
fn extract_keeps_type3_glyph_resources_of_an_undecodable_form() {
let part = full_roundtrip(&extract_pages(&doc_type3_resources_of_undecodable_content(true), &[0, 1]).unwrap());
assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "an unreadable form's font shares a different page's XObject dictionary");
}
#[test]
fn extract_still_prunes_separate_resources_when_many_pages_share_a_type3_font() {
let mut src = doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>");
src.set(ObjRef::new(4, 0), Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"/ImA Do".to_vec())));
let page = src.get(ObjRef::new(3, 0)).as_dict().unwrap().clone();
let mut kids = vec![Object::Ref(ObjRef::new(3, 0))];
for _ in 1..4_000 {
kids.push(Object::Ref(src.add(page.clone())));
}
src.update_dict(ObjRef::new(2, 0), |pages| {
pages.set(b"Kids".to_vec(), Object::Array(kids));
pages.set(b"Count".to_vec(), Object::Int(4_000));
})
.unwrap();
let src = full_roundtrip(&src);
let part = full_roundtrip(&extract_pages(&src, &(0..4_000).collect::<Vec<_>>()).unwrap());
assert_eq!(xobject_names(&part, 0), [b"ImA".to_vec()], "one shared font's direct resource graph is protected once");
}
#[test]
#[ignore = "requires the external Poppler pdftoppm oracle"]
fn extract_type3_shared_xobjects_matches_poppler_pixels() {
let dir = std::env::temp_dir().join(format!("pdfcraft-type3-extract-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let fixtures = [
doc_type3_shared_xobjects("<< /XObject 6 0 R >>"),
doc_type3_shared_xobjects("5 0 R"),
doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>"),
doc_type3_glyph_form_with_shared_resources(false),
doc_type3_glyph_form_with_shared_resources(true),
];
for (i, src) in fixtures.into_iter().enumerate() {
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
let render = |doc: &Document, name: &str| {
let path = dir.join(format!("{i}-{name}.pdf"));
std::fs::write(&path, write_full(doc, &SaveOptions::default()).unwrap()).unwrap();
let output = std::process::Command::new("pdftoppm").args(["-singlefile", "-r", "72", "-gray"]).arg(path).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr));
output.stdout
};
let before = render(&src, "source");
let after = render(&part, "extracted");
assert!(before.starts_with(b"P5\n200 200\n255\n"));
assert!(before.iter().filter(|&&v| v < 200).count() > 2500, "the source contains a drawn glyph");
assert_eq!(after, before, "extraction must keep every rendered pixel in fixture {i}");
}
std::fs::remove_dir_all(dir).unwrap();
}
/// A page listed twice whose widget's field keeps `/Kids` as an array object of its own: the
/// second widget joins that array, and the field keeps every widget it had.
#[test]

251
crates/organize/src/xmp.rs Normal file
View File

@ -0,0 +1,251 @@
//! Keeping the catalog's XMP metadata (`/Root /Metadata`, ISO 32000-2 §14.3.2) in step with the
//! document information when Document Properties ▸ Description changes it.
//!
//! Files that carry XMP state the title and author twice, and viewers that read XMP first show
//! the packet's value, so changing only `/Info` leaves the file disagreeing with itself. Each
//! information key maps to the XMP property PDF/A pairs it with (ISO 19005-1 §6.7.3, the same
//! mapping `pdfcraft-preflight` writes): Title → `dc:title`, Author → `dc:creator`, Subject →
//! `dc:description`, Keywords → `pdf:Keywords`, Creator → `xmp:CreatorTool`, Producer →
//! `pdf:Producer`.
//!
//! The packet is edited as text, touching only the property that changed: everything else in it
//! (other schemas, padding, unknown properties) stays byte for byte. A file without XMP gets
//! none, and a packet this can't edit safely (not UTF-8, no `rdf:RDF`, too large to decode) is
//! left as it was.
use pdfcraft_cos::{Document, Object, Stream};
use crate::OrganizeError;
use crate::pdfx::esc;
/// Decoding limit for an XMP packet (they are a few kB; padding rarely takes them past 100 kB).
const MAX_XMP: usize = 16 << 20;
const DC: &str = "http://purl.org/dc/elements/1.1/";
const PDF: &str = "http://ns.adobe.com/pdf/1.3/";
const XMP: &str = "http://ns.adobe.com/xap/1.0/";
/// How an XMP property holds its value.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Shape {
/// A language alternative: `rdf:Alt` with an `x-default` item.
LangAlt,
/// An ordered array: `rdf:Seq` (the authors).
Seq,
/// Plain text.
Text,
}
/// The XMP property (qualified name, namespace) that mirrors a document information key.
fn property(key: &str) -> Option<(&'static str, &'static str, Shape)> {
Some(match key {
"Title" => ("dc:title", DC, Shape::LangAlt),
"Author" => ("dc:creator", DC, Shape::Seq),
"Subject" => ("dc:description", DC, Shape::LangAlt),
"Keywords" => ("pdf:Keywords", PDF, Shape::Text),
"Creator" => ("xmp:CreatorTool", XMP, Shape::Text),
"Producer" => ("pdf:Producer", PDF, Shape::Text),
_ => return None,
})
}
/// After the information entry `key` became `value` (empty: removed), make the catalog's XMP
/// packet say the same. Does nothing for keys XMP doesn't mirror, files without XMP, and packets
/// that can't be edited safely.
pub(crate) fn sync_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> {
let Some((name, ns, shape)) = property(key) else { return Ok(()) };
let Some(root) = doc.root() else { return Ok(()) };
// A metadata stream is always indirect (§7.3.8); anything else is left alone.
let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"Metadata")) else { return Ok(()) };
let obj = doc.get(r);
let Object::Stream(stream) = &*obj else { return Ok(()) };
let Ok(bytes) = stream.decoded_within(MAX_XMP) else { return Ok(()) };
let Ok(xmp) = String::from_utf8(bytes) else { return Ok(()) };
let Some(updated) = set_property(&xmp, name, ns, shape, value) else { return Ok(()) };
// Written unfiltered, as PDF/A asks of metadata streams, so other tools can read it.
let mut dict = stream.dict.clone();
for k in [&b"Filter"[..], b"DecodeParms", b"DL", b"Length"] {
dict.remove(k);
}
doc.set(r, Object::Stream(Stream::from_raw(dict, updated.into_bytes())));
Ok(())
}
/// `xmp` with the property `name` (namespace `ns`) set to `value`, or removed when `value` is
/// empty; `None` when nothing changes or the packet has no `rdf:RDF` to add the property to.
fn set_property(xmp: &str, name: &str, ns: &str, shape: Shape, value: &str) -> Option<String> {
let element = (!value.is_empty()).then(|| match shape {
Shape::LangAlt => format!("<{name}><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></{name}>", esc(value)),
Shape::Seq => format!("<{name}><rdf:Seq><rdf:li>{}</rdf:li></rdf:Seq></{name}>", esc(value)),
Shape::Text => format!("<{name}>{}</{name}>", esc(value)),
});
// The element form, `<dc:title>…</dc:title>` (or `<dc:title/>`): replaced where it stands, so
// it keeps the namespace declaration of the description around it.
if let Some((start, end)) = element_span(xmp, name) {
let mut out = String::with_capacity(xmp.len());
out.push_str(xmp.get(..start)?);
out.push_str(element.as_deref().unwrap_or_default());
out.push_str(xmp.get(end..)?);
return Some(out);
}
// The attribute form, `pdf:Keywords="…"` on an `rdf:Description`: only simple text can be
// written there; a structured value moves to an element of its own.
if let Some((start, value_start, value_end, quote)) = attribute_span(xmp, name) {
let mut out = String::with_capacity(xmp.len());
if shape == Shape::Text && !value.is_empty() {
let escaped = if quote == '"' { esc(value) } else { esc(value).replace('\'', "&apos;") };
out.push_str(xmp.get(..value_start)?);
out.push_str(&escaped);
out.push_str(xmp.get(value_end..)?);
return Some(out);
}
out.push_str(xmp.get(..start)?);
out.push_str(xmp.get(value_end + 1..)?);
return match element {
Some(e) => insert_description(&out, name, ns, &e),
None => Some(out),
};
}
insert_description(xmp, name, ns, element.as_deref()?)
}
/// `xmp` with a new `rdf:Description` holding `element` (and declaring its namespace) as the last
/// description of the packet.
fn insert_description(xmp: &str, name: &str, ns: &str, element: &str) -> Option<String> {
let prefix = name.split(':').next()?;
let at = xmp.rfind("</rdf:RDF>")?;
// Every description of a packet describes the same resource (XMP: one `rdf:about` value).
let about = attribute_span(xmp, "rdf:about").and_then(|(_, s, e, _)| xmp.get(s..e)).unwrap_or_default().replace('"', "&quot;");
let mut out = String::with_capacity(xmp.len() + element.len() + 128);
out.push_str(xmp.get(..at)?);
out.push_str(&format!("<rdf:Description rdf:about=\"{about}\" xmlns:{prefix}=\"{ns}\">{element}</rdf:Description>\n"));
out.push_str(xmp.get(at..)?);
Some(out)
}
/// Where the element `name` is in `xmp`, from its `<` to past its end tag (or past `/>`).
fn element_span(xmp: &str, name: &str) -> Option<(usize, usize)> {
let open = format!("<{name}");
let mut from = 0;
while let Some(i) = xmp.get(from..).and_then(|s| s.find(&open)).map(|i| i + from) {
let after = i + open.len();
from = after;
// `<dc:title` must end there: not `<dc:titles`.
match xmp.get(after..).and_then(|s| s.chars().next()) {
Some('>' | '/') => {}
Some(c) if c.is_whitespace() => {}
_ => continue,
}
let tag_end = after + xmp.get(after..)?.find('>')?;
if xmp.get(..tag_end)?.ends_with('/') {
return Some((i, tag_end + 1));
}
let close = format!("</{name}>");
let end = tag_end + xmp.get(tag_end..)?.find(&close)? + close.len();
return Some((i, end));
}
None
}
/// Where the attribute `name` is in `xmp`: its start (including the whitespace before it), the
/// start and end of its value, and its quote character. Only matches inside a start tag.
fn attribute_span(xmp: &str, name: &str) -> Option<(usize, usize, usize, char)> {
let mut from = 0;
while let Some(i) = xmp.get(from..).and_then(|s| s.find(name)).map(|i| i + from) {
from = i + name.len();
let before = xmp.get(..i)?;
// Preceded by whitespace, and inside a tag (the last `<` comes after the last `>`).
if !before.ends_with(char::is_whitespace) || before.rfind('<') < before.rfind('>') || before.rfind('<').is_none() {
continue;
}
let rest = xmp.get(from..)?;
let trimmed = rest.trim_start();
let Some(after_eq) = trimmed.strip_prefix('=') else { continue };
let after_eq = after_eq.trim_start();
let Some(quote) = after_eq.chars().next().filter(|c| matches!(c, '"' | '\'')) else { continue };
let value_start = xmp.len() - after_eq.len() + 1;
let value_end = value_start + xmp.get(value_start..)?.find(quote)?;
let start = before.trim_end().len();
return Some((start, value_start, value_end, quote));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
/// The shape of a LibreOffice export: one description per schema, element forms.
const PACKET: &str = "<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n\
<rdf:Description rdf:about=\"\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\">\n <pdf:Producer>Writer 1.0</pdf:Producer>\n </rdf:Description>\n\
<rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n\
<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">Untitled 1</rdf:li></rdf:Alt></dc:title>\n\
<dc:creator><rdf:Seq><rdf:li>Old Author</rdf:li></rdf:Seq></dc:creator>\n\
<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">Old subject</rdf:li></rdf:Alt></dc:description>\n\
</rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>";
#[test]
fn element_values_are_replaced_in_place() {
let out = set_property(PACKET, "dc:title", DC, Shape::LangAlt, "Guide & <draft>").unwrap();
assert!(out.contains("<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">Guide &amp; &lt;draft&gt;</rdf:li></rdf:Alt></dc:title>\n"), "{out}");
assert!(!out.contains("Untitled 1"));
// Everything else is untouched.
assert_eq!(out.replace("Guide &amp; &lt;draft&gt;", "Untitled 1"), PACKET);
let out = set_property(PACKET, "dc:creator", DC, Shape::Seq, "Demo Consulting").unwrap();
assert!(out.contains("<dc:creator><rdf:Seq><rdf:li>Demo Consulting</rdf:li></rdf:Seq></dc:creator>"), "{out}");
}
#[test]
fn an_empty_value_removes_the_property() {
let out = set_property(PACKET, "dc:description", DC, Shape::LangAlt, "").unwrap();
assert!(!out.contains("dc:description"), "{out}");
assert!(out.contains("Untitled 1") && out.contains("Old Author"));
// Nothing to remove: no change.
assert_eq!(set_property(PACKET, "pdf:Keywords", PDF, Shape::Text, ""), None);
}
#[test]
fn a_missing_property_gets_a_description_of_its_own() {
let out = set_property(PACKET, "pdf:Keywords", PDF, Shape::Text, "alpha, beta").unwrap();
let added =
"<rdf:Description rdf:about=\"\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\"><pdf:Keywords>alpha, beta</pdf:Keywords></rdf:Description>\n";
assert!(out.find(added).is_some_and(|at| at > out.find("<dc:creator>").unwrap() && at < out.find("</rdf:RDF>").unwrap()), "{out}");
assert_eq!(out.replacen(added, "", 1), PACKET);
// A packet with no rdf:RDF to add to is left alone.
assert_eq!(set_property("<x:xmpmeta/>", "pdf:Keywords", PDF, Shape::Text, "alpha"), None);
}
#[test]
fn attribute_values_are_rewritten_where_they_stand() {
let packet = "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"r\"><rdf:Description rdf:about='uuid:1' \
xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmp:CreatorTool='Old \"tool\"' dc:title=\"Odd\"/></rdf:RDF></x:xmpmeta>";
let out = set_property(packet, "xmp:CreatorTool", XMP, Shape::Text, "Writer's tool").unwrap();
assert!(out.contains("xmp:CreatorTool='Writer&apos;s tool'"), "{out}");
// A title can't be an attribute: it moves to an element, in a description for the same resource.
let out = set_property(packet, "dc:title", DC, Shape::LangAlt, "Guide").unwrap();
assert!(!out.contains("dc:title=\"Odd\""), "{out}");
assert!(out.contains("<rdf:Description rdf:about=\"uuid:1\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title><rdf:Alt>"), "{out}");
let out = set_property(packet, "xmp:CreatorTool", XMP, Shape::Text, "").unwrap();
assert!(out.contains("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" dc:title=\"Odd\"/>"), "{out}");
}
#[test]
fn lookalike_names_and_text_are_not_matched() {
let packet = "<rdf:RDF xmlns:rdf=\"r\"><rdf:Description rdf:about=\"\"><dc:titles>keep</dc:titles>\
<dc:subject>say dc:title=\"no\"</dc:subject><dc:title/></rdf:Description></rdf:RDF>";
let out = set_property(packet, "dc:title", DC, Shape::LangAlt, "New").unwrap();
assert!(out.contains("<dc:titles>keep</dc:titles>") && out.contains("say dc:title=\"no\""), "{out}");
assert!(out.contains("<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">New</rdf:li></rdf:Alt></dc:title></rdf:Description>"), "{out}");
}
#[test]
fn hostile_packets_do_not_panic() {
for packet in ["", "<", "<dc:title", "<dc:title>", "<dc:title x='", " dc:title=", "<a dc:title=\"", "</rdf:RDF>", "é<dc:title é>"] {
for value in ["", "v"] {
let _ = set_property(packet, "dc:title", DC, Shape::LangAlt, value);
let _ = set_property(packet, "pdf:Keywords", PDF, Shape::Text, value);
}
}
}
}

View File

@ -71,7 +71,7 @@ this is not per-page paper-source selection.
**PDF scale** describes the source-to-sheet transform in the previewed print-ready
PDF, not measured physical output. Fit reserves an 18 pt margin and can reduce even
a matching page. The current Windows backend still rasterises and can substitute
paper/shrink a job if its driver lacks the requested size. These controls do not
add #599's strict PrintTicket validation or vector spooler. Verify the driver's
paper/scaling settings and physical calibration before relying on drawing scale.
a matching page. On Windows craft-print sends the sheets as vectors with a PrintTicket
for the sheet's exact paper and no scaling (#599's approach), validated by the driver:
a job is refused, not shrunk, when the driver lacks the paper or would change it. Verify
the driver's paper settings and physical calibration before relying on drawing scale.

View File

@ -162,6 +162,9 @@ pub enum Layout {
Multiple {
cols: usize,
rows: usize,
/// Source pages on each sheet, at most `cols × rows`: three per sheet uses a 2 × 2 grid
/// with one cell left blank.
per_sheet: usize,
order: PageOrder,
border: bool,
auto_rotate: bool,
@ -198,7 +201,7 @@ impl Layout {
(c, n.div_ceil(c).max(1))
}
};
Layout::Multiple { cols, rows, order: PageOrder::Horizontal, border: false, auto_rotate: true }
Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true }
}
}
@ -343,11 +346,16 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result<Vec<Sheet>, P
sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() });
}
}
Layout::Multiple { cols, rows, order, border, auto_rotate } => {
Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => {
let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256);
let Some(cells) = cells else {
return Err(PrintError::Invalid("invalid number of pages per sheet".into()));
};
// The grid can have more cells than the pages asked for on each sheet; the rest stay blank.
let per_sheet = per_sheet.min(cells);
if per_sheet == 0 {
return Err(PrintError::Invalid("invalid number of pages per sheet".into()));
}
// A grid with more columns than rows wants a landscape sheet, and the reverse.
let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?;
let page_landscape = first.0 > first.1;
@ -366,7 +374,7 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result<Vec<Sheet>, P
if cw <= 0.0 || ch <= 0.0 {
return Err(PrintError::Invalid("the page grid does not fit on the paper".into()));
}
let sheet_count = pages.len().div_ceil(cells);
let sheet_count = pages.len().div_ceil(per_sheet);
for sheet_index in 0..sheet_count {
let mut sheet = Sheet { size, ..Sheet::default() };
if order == PageOrder::CutStack {
@ -381,13 +389,13 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result<Vec<Sheet>, P
sheet.lines.extend([[MARGIN, y, MARGIN + 8.0, y], [size.0 - MARGIN - 8.0, y, size.0 - MARGIN, y]]);
}
}
for k in 0..cells {
for k in 0..per_sheet {
// A cell's pile gets consecutive pages. Missing pages leave blank cells,
// rather than shifting later piles left on the final sheets.
let index = if order == PageOrder::CutStack {
k.checked_mul(sheet_count).and_then(|n| n.checked_add(sheet_index))
} else {
sheet_index.checked_mul(cells).and_then(|n| n.checked_add(k))
sheet_index.checked_mul(per_sheet).and_then(|n| n.checked_add(k))
};
let Some(p) = index.and_then(|i| pages.get(i)).copied() else { continue };

View File

@ -155,9 +155,11 @@ fn multiple_pages_per_sheet() {
assert_eq!(two[0].size, (792.0, 612.0));
assert!(two[0].placed[1].matrix.0[4] > two[0].placed[0].matrix.0[4]);
// Vertical order and borders.
let v =
layout(&sizes, &settings(vec![0, 1, 2], Layout::Multiple { cols: 2, rows: 2, order: PageOrder::Vertical, border: true, auto_rotate: false }))
.unwrap();
let v = layout(
&sizes,
&settings(vec![0, 1, 2], Layout::Multiple { cols: 2, rows: 2, per_sheet: 4, order: PageOrder::Vertical, border: true, auto_rotate: false }),
)
.unwrap();
assert!(close(v[0].placed[1].matrix.0[4], v[0].placed[0].matrix.0[4]) && v[0].placed[1].matrix.0[5] < v[0].placed[0].matrix.0[5]);
assert_eq!(v[0].borders.len(), 3);
// Auto-rotate turns a landscape page in a portrait cell.
@ -168,6 +170,28 @@ fn multiple_pages_per_sheet() {
assert_eq!(r[0].placed[1].matrix.0[0], 0.0, "rotated");
}
#[test]
fn custom_pages_per_sheet_never_overfill_the_grid() {
// Three per sheet draws on a 2 × 2 grid, five on 3 × 2: the spare cells stay blank (#1336).
let sizes = vec![(200.0, 300.0); 7];
let pages = |per: usize| sheet_pages(&layout(&sizes, &settings((0..7).collect(), Layout::multiple(per))).unwrap());
assert_eq!(pages(3), [vec![0, 1, 2], vec![3, 4, 5], vec![6]]);
assert_eq!(pages(5), [vec![0, 1, 2, 3, 4], vec![5, 6]]);
assert_eq!(pages(7), [vec![0, 1, 2, 3, 4, 5, 6]]);
assert_eq!(pages(4), [vec![0, 1, 2, 3], vec![4, 5, 6]]);
assert_eq!(pages(2), [vec![0, 1], vec![2, 3], vec![4, 5], vec![6]]);
// Cut-stack piles take the requested count too.
let stacked = Layout::Multiple { cols: 2, rows: 2, per_sheet: 3, order: PageOrder::CutStack, border: false, auto_rotate: false };
assert_eq!(sheet_pages(&layout(&sizes, &settings((0..7).collect(), stacked)).unwrap()), [vec![0, 3, 6], vec![1, 4], vec![2, 5]]);
// A count larger than the grid is capped by the grid; no pages per sheet is refused.
let grid = |per_sheet| Layout::Multiple { cols: 2, rows: 1, per_sheet, order: PageOrder::Horizontal, border: false, auto_rotate: false };
assert_eq!(sheet_pages(&layout(&sizes[..3], &settings(vec![0, 1, 2], grid(9))).unwrap()), [vec![0, 1], vec![2]]);
assert!(matches!(layout(&sizes, &settings(vec![0], grid(0))), Err(PrintError::Invalid(_))));
// The imposed PDF has one page per sheet.
let out = impose(&fixture(7), &settings((0..7).collect(), Layout::multiple(3))).unwrap();
assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), 3);
}
#[test]
fn booklets_pair_pages_for_folding() {
let sizes = vec![(200.0, 300.0); 6];
@ -309,7 +333,7 @@ fn driverless_printers_join_the_list_without_a_queue() {
}
fn cut_stack(cols: usize, rows: usize) -> Layout {
Layout::Multiple { cols, rows, order: PageOrder::CutStack, border: false, auto_rotate: false }
Layout::Multiple { cols, rows, per_sheet: cols.saturating_mul(rows), order: PageOrder::CutStack, border: false, auto_rotate: false }
}
#[test]
@ -362,7 +386,7 @@ fn cutting_and_stacking_recovers_every_selected_page() {
fn multiple_rejects_hostile_grids_without_panicking() {
let sizes = [(200.0, 300.0)];
for (cols, rows) in [(usize::MAX, 2), (2, usize::MAX), (0, 2), (1, 0), (257, 1)] {
let mode = Layout::Multiple { cols, rows, order: PageOrder::Horizontal, border: false, auto_rotate: false };
let mode = Layout::Multiple { cols, rows, per_sheet: 1, order: PageOrder::Horizontal, border: false, auto_rotate: false };
assert!(matches!(layout(&sizes, &settings(vec![0], mode)), Err(PrintError::Invalid(_))));
}
let tiny = Settings { paper: (72.0, 72.0), ..settings(vec![0], cut_stack(16, 16)) };

View File

@ -36,6 +36,11 @@ leave it unredacted.
Structure cleanup resolves named MCIDs and indirect content references. Form content
references are updated to the cleaned copy; unchanged sibling descriptions remain.
A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference;
a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's
`/Resources` is then retired unless another page still inherits it, so it cannot keep a removed
XObject reachable. Pages that aren't redacted are left untouched.
## Remove hidden information / Sanitize
```rust

View File

@ -23,7 +23,7 @@ use std::rc::Rc;
use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use crate::{Report, image, tags};
use crate::{RedactError, Report, image, page_streams, tags};
use pdfcraft_fonts::pdf::Metrics;
const MAX_DEPTH: usize = 12;
@ -62,6 +62,10 @@ pub(crate) struct Output {
/// can unlink the original from the resource dictionary (a full save keeps whatever is still
/// referenced, which would otherwise retain the removed image or form bytes).
pub replaced: Vec<Vec<u8>>,
/// The original objects behind `replaced`, nested scopes included. Other resource
/// dictionaries may name them too (an inherited or shared /Resources, or the caller's when a
/// form has none of its own): [`unlink_doomed`] removes those entries.
pub doomed: Vec<ObjRef>,
/// Glyphs / inline images still overlapping a region (verify mode).
pub residue: usize,
/// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was
@ -203,6 +207,117 @@ pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec<u8>], content: &[Vec<u8>]) {
}
}
/// Whether `content` draws the XObject `name` of the resource dictionary `xo`: directly, or
/// through a form that has no /Resources of its own (such a form draws through its caller's).
/// `None` when that cannot be told (a form that will not decode, or nesting past the limit).
fn content_draws(doc: &Document, content: &[Vec<u8>], xo: &Dict, name: &[u8], depth: usize, seen: &mut HashSet<ObjRef>) -> Option<bool> {
let joined = Pieces::join(content);
let ops = joined.parse().ops;
for op in ops.iter().filter(|op| op.is("Do")) {
let Some(n) = op.name(0) else { continue };
if n == name {
return Some(true);
}
let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue };
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { continue };
if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) {
continue;
}
if depth >= MAX_DEPTH {
return None;
}
let Ok(data) = s.decoded() else { return None };
match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) {
Some(false) => {}
other => return other,
}
}
Some(false)
}
/// Unlink the XObjects a rewrite dropped or replaced (`doomed`) from every page-tree resource
/// dictionary that still names them. The rewritten page has its own pruned /Resources, but the
/// originals stay reachable, and so stay in a full-rewrite save, when:
/// - the page inherited its /Resources from a /Pages node, which still lists them;
/// - several pages share one resource dictionary, and another page still refers to it;
/// - a form without /Resources drew them through the page's dictionary.
///
/// An entry goes unless a page that draws through that dictionary still draws the name (the same
/// image shown 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 as it is for them. Fails closed when a page's use of a name cannot be told.
pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(), RedactError> {
if doomed.is_empty() {
return Ok(());
}
let doomed: HashSet<ObjRef> = doomed.iter().copied().collect();
let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return Ok(()) };
// Every page-tree node with a /Resources of its own, and the pages that draw through it
// (the nearest such node above a page, or the page itself). Cycles are cut as in
// `pdfcraft_model::pages`.
let mut holders: Vec<(ObjRef, Vec<ObjRef>)> = Vec::new();
let mut seen = HashSet::new();
let mut stack: Vec<(ObjRef, Option<usize>)> = vec![(top, None)];
while let Some((node, mut holder)) = stack.pop() {
if !seen.insert(node) || seen.len() > 1_000_000 {
continue;
}
let obj = doc.get(node);
let Some(d) = obj.as_dict() else { continue };
if d.contains(b"Resources") {
holders.push((node, Vec::new()));
holder = Some(holders.len() - 1);
}
let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page"));
if is_pages {
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder)));
} else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) {
h.1.push(node);
}
}
let index: HashMap<ObjRef, usize> = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
for (node, users) in holders {
let Some(res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue };
let mut xo = res_dict(doc, &res, b"XObject");
let names: Vec<Vec<u8>> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect();
if names.is_empty() {
continue;
}
let mut contents = Vec::with_capacity(users.len());
for user in &users {
let page = doc.get(*user).as_dict().cloned().unwrap_or_default();
// A page whose content cannot be read may still draw the name: fail closed.
contents.push(page_streams(doc, &page, index.get(user).copied().unwrap_or(0))?.1);
}
let mut pruned = false;
for n in names {
let mut drawn = Some(false);
for content in &contents {
drawn = content_draws(doc, content, &xo, &n, 0, &mut HashSet::new());
if drawn != Some(false) {
break;
}
}
match drawn {
Some(true) => {}
Some(false) => {
xo.remove(&n);
pruned = true;
}
None => return Err(RedactError::Residue(1)),
}
}
if pruned {
let mut res = res;
res.set(b"XObject".to_vec(), Object::Dict(xo));
doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
}
}
Ok(())
}
/// The clip that hides everything inside each region: one even-odd clip per region (a big
/// rectangle with the region cut out), in the current user space. Returns `None` when the CTM
/// can't be inverted (nothing is painted then anyway).
@ -607,6 +722,7 @@ fn xobject(
}
scope.layer_blocks += 1;
out.replaced.push(name);
out.doomed.push(r);
Some(None)
}
Some(b"Image") => {
@ -617,6 +733,7 @@ fn xobject(
if scope.covered(b) {
scope.report.images_removed += 1;
out.replaced.push(name);
out.doomed.push(r);
return Some(None);
}
match image::clear(doc, s, &gs.ctm, scope.rects) {
@ -627,12 +744,14 @@ fn xobject(
xobjects.set(n.clone(), Object::Ref(nr));
out.xobjects.push((n.clone(), nr));
out.replaced.push(name);
out.doomed.push(r);
scope.report.images_cleared += 1;
Some(Some(Op::new("Do", vec![Object::Name(n)])))
}
Err(()) => {
scope.report.images_removed += 1;
out.replaced.push(name);
out.doomed.push(r);
Some(None)
}
}
@ -656,6 +775,7 @@ fn xobject(
}
scope.report.forms_removed += 1;
out.replaced.push(name);
out.doomed.push(r);
return Some(None);
}
let data = match s.decoded() {
@ -663,6 +783,7 @@ fn xobject(
Err(_) if scope.mode == Mode::Apply => {
scope.report.forms_removed += 1;
out.replaced.push(name);
out.doomed.push(r);
return Some(None);
}
Err(_) => return None,
@ -676,6 +797,7 @@ fn xobject(
scope.fonts = saved_fonts;
out.residue += inner.residue;
out.vertical |= inner.vertical;
out.doomed.extend_from_slice(&inner.doomed);
out.tag_changes.extend(inner.tag_changes);
if scope.mode == Mode::Verify {
return None;
@ -711,6 +833,7 @@ fn xobject(
xobjects.set(n.clone(), Object::Ref(nr));
out.xobjects.push((n.clone(), nr));
out.replaced.push(name);
out.doomed.push(r);
scope.report.forms_rewritten += 1;
Some(Some(Op::new("Do", vec![Object::Name(n)])))
}

View File

@ -14,6 +14,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
pub mod codes;
@ -40,7 +42,9 @@ pub enum RedactError {
/// A mark's overlay text has a character Helvetica can't draw; nothing was redacted.
#[error("{0}")]
Undrawable(String),
#[error("page {0} has vertical-writing text under a redaction mark, which PdfCraft cannot remove yet, so nothing was changed")]
#[error(
"page {0} has vertical-writing text while a redaction mark is pending, and PdfCraft cannot tell whether that text lies under the mark yet, so nothing was changed"
)]
VerticalText(usize),
#[error(transparent)]
Edit(#[from] pdfcraft_edit::EditError),
@ -222,12 +226,13 @@ fn overlay_content(marks: &[&Mark]) -> Vec<u8> {
// The overlay text: its font, colour and alignment; auto-sized to fit unless a size is
// set; repeated to fill the area when asked.
let look = &m.look;
let (res, width): (&str, fn(&str, f64) -> f64) = match look.font {
pdfcraft_annot::OverlayFont::Helvetica => ("PCHelv", pdfcraft_fonts::helvetica_width),
// Approximations of the standard metrics (no font program is bundled).
pdfcraft_annot::OverlayFont::Times => ("PCTimes", |s, size| pdfcraft_fonts::helvetica_width(s, size) * 0.9),
pdfcraft_annot::OverlayFont::Courier => ("PCCour", |s, size| s.chars().count() as f64 * size * 0.6),
// The published standard-14 metrics, so overlay text is measured as it will be drawn.
let (res, face) = match look.font {
pdfcraft_annot::OverlayFont::Helvetica => ("PCHelv", pdfcraft_fonts::Std14::Helvetica),
pdfcraft_annot::OverlayFont::Times => ("PCTimes", pdfcraft_fonts::Std14::TimesRoman),
pdfcraft_annot::OverlayFont::Courier => ("PCCour", pdfcraft_fonts::Std14::Courier),
};
let width = move |s: &str, size: f64| face.text_width(s, size);
let [cr, cg, cb] = look.color.map(|v| v.clamp(0.0, 1.0));
for r in &m.rects {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
@ -271,6 +276,97 @@ fn overlay_content(marks: &[&Mark]) -> Vec<u8> {
c
}
/// The nearest ancestor of `page` that holds `/Resources` (where an inheriting page gets them):
/// `Some(None)` when none does, `None` when the chain can't be followed (a cycle, a broken node or
/// a tree deeper than any real one), so callers can stay conservative.
fn resource_source(doc: &Document, page: ObjRef) -> Option<Option<ObjRef>> {
let mut seen = HashSet::new();
let mut parent = doc.get(page).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref);
while let Some(r) = parent {
if !seen.insert(r) || seen.len() > 4096 {
return None;
}
let node = doc.get(r);
let d = node.as_dict()?;
if d.contains(b"Resources") {
return Some(Some(r));
}
parent = d.get(b"Parent").and_then(Object::as_ref);
}
Some(None)
}
/// Before rewriting `selected` pages, pin the resources they inherit onto them, and retire the
/// ancestor `/Resources` they came from once no other page still inherits it, so the parent can't
/// keep a removed XObject reachable. Pages that aren't redacted are left untouched; an inherited
/// dictionary that is direct becomes one shared indirect object, never a copy per page.
fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfcraft_cos::CosError> {
let pages = pdfcraft_model::pages(doc);
let inherits = |doc: &Document, p: &pdfcraft_model::Page| doc.get(p.obj).as_dict().is_some_and(|d| !d.contains(b"Resources"));
// Ancestor → the reference its resources are shared through, and whether that reference is new.
let mut shared: HashMap<ObjRef, (Object, bool)> = HashMap::new();
for page in selected.iter().filter_map(|&i| pages.get(i)) {
// What the page tree gives the page; nothing to pin if it gets nothing.
let Some(effective) = page.dict.get(b"Resources").cloned() else { continue };
if !inherits(doc, page) {
continue;
}
let source = resource_source(doc, page.obj).flatten();
let original = source.and_then(|s| doc.get(s).as_dict().and_then(|d| d.get(b"Resources").cloned()));
let value = match source.filter(|_| original.as_ref() == Some(&effective)) {
// `/Parent` agrees with the page tree: share one reference per ancestor.
Some(source) => match shared.get(&source) {
Some((v, _)) => v.clone(),
None => {
// A direct dictionary is made indirect once, so every pinned page shares it.
let (v, new) = match effective {
Object::Ref(_) => (effective, false),
direct => (Object::Ref(doc.add(direct)), true),
};
shared.insert(source, (v.clone(), new));
v
}
},
// A malformed tree whose `/Parent` disagrees: pin this page alone and retire nothing.
None => match effective {
Object::Ref(_) => effective,
direct => Object::Ref(doc.add(direct)),
},
};
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), value))?;
}
if shared.is_empty() {
return Ok(());
}
// Ancestors some page still inherits from keep their resources (that page draws them), through
// the shared reference; the others are retired. A page that inherits resources through a chain
// that can't be followed might inherit from any of them, so then none is retired.
let mut still_used: HashSet<ObjRef> = HashSet::new();
for page in pages.iter().filter(|p| p.dict.contains(b"Resources") && inherits(doc, p)) {
match resource_source(doc, page.obj) {
Some(Some(source)) => {
if shared.contains_key(&source) {
still_used.insert(source);
}
}
_ => still_used.extend(shared.keys().copied()),
}
}
for (source, (value, new)) in shared {
if still_used.contains(&source) {
if !new {
continue;
}
doc.update_dict(source, |d| d.set(b"Resources".to_vec(), value))?;
} else {
doc.update_dict(source, |d| {
d.remove(b"Resources");
})?;
}
}
Ok(())
}
/// Apply every redaction mark (or only those on `pages`, 0-based). Irreversible for the saved
/// file; callers keep the previous document for undo.
pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, RedactError> {
@ -292,7 +388,10 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
by_page.sort_unstable();
by_page.dedup();
report.pages = by_page.len();
localize_resources(doc, &by_page)?;
let mut doomed_fields: Vec<String> = Vec::new();
// The original image and form XObjects the rewrite drops or replaces, on any page.
let mut doomed_xobjects: Vec<ObjRef> = Vec::new();
let widget_owner: Vec<(ObjRef, String)> =
pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::<Vec<_>>()).collect();
@ -309,12 +408,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY)
};
// Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot
// place such glyphs under a mark or confirm they are gone. Rather than report success and
// ship the text under the box, refuse the whole operation (the caller keeps the previous
// document). Full vertical-text removal is a future enhancement.
// tell whether those glyphs lie under a mark. Rather than report success and possibly
// leave the text in place, refuse the whole operation (the caller keeps the previous
// document). The error does not claim the text was under the mark. Full vertical-text
// removal is a future enhancement.
if out.vertical {
return Err(RedactError::VerticalText(pi + 1));
}
doomed_xobjects.extend_from_slice(&out.doomed);
let mut new_list = list.clone();
let mut changed = false;
for (i, new) in out.streams.into_iter().enumerate() {
@ -465,6 +566,10 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
}
}
// A removed image or form must not stay reachable through another resource dictionary (an
// inherited or shared /Resources): a full save keeps whatever is still referenced.
interp::unlink_doomed(doc, &doomed_xobjects)?;
// 5. Verify: nothing readable may remain under a region.
let mut residue = 0;
for &pi in &by_page {

View File

@ -144,6 +144,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
return Ok(0);
}
let mut total = 0;
// The original image and form XObjects the rewrite drops or replaces, on any page.
let mut doomed = Vec::new();
let n = pdfcraft_model::pages(doc).len();
for pi in 0..n {
let page = pdfcraft_model::pages(doc).swap_remove(pi);
@ -168,6 +170,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
if !write {
continue;
}
doomed.extend_from_slice(&out.doomed);
let mut new_list = list.clone();
let mut changed = false;
for (i, new) in out.streams.into_iter().enumerate() {
@ -205,6 +208,9 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
})?;
}
}
// What a page no longer draws must not stay reachable through an inherited or shared
// /Resources either.
crate::interp::unlink_doomed(doc, &doomed)?;
Ok(total)
}

View File

@ -236,7 +236,11 @@ fn vertical_writing_text_under_a_mark_fails_closed() {
// Identity-V: apply must refuse rather than report success with the text still in the file.
let mut vdoc = make("Identity-V");
mark(&mut vdoc, 0, &[[85.0, 55.5, 105.0, 61.0]], "");
assert_eq!(apply(&mut vdoc, None), Err(RedactError::VerticalText(1)), "vertical text under a mark must not silently pass");
let err = apply(&mut vdoc, None).unwrap_err();
assert_eq!(err, RedactError::VerticalText(1), "vertical text with a pending mark must not silently pass");
let msg = err.to_string();
assert!(msg.contains("cannot tell whether"), "{msg}");
assert!(!msg.contains("text under a redaction mark"), "{msg}");
// Identity-H control: the same glyphs and mark are redacted normally.
let mut hdoc = make("Identity-H");
@ -351,6 +355,120 @@ fn redacted_images_and_forms_do_not_survive_a_full_rewrite_save() {
assert_eq!(s.decoded().unwrap(), imkeep, "the unrelated image survives intact");
}
/// Two pages that draw through one resource dictionary: inherited from the /Pages node, or one
/// dictionary object both pages refer to. Page 1 draws a covered image, a partly covered image
/// and a form whose text is under the mark; page 2 draws /ImKeep, and /Im1 too when `again`.
fn two_pages_one_resource_dictionary(inherited: bool, again: bool) -> Document {
let res = "<< /Font << /F1 5 0 R >> /XObject << /Im1 7 0 R /ImPart 8 0 R /FmSecret 9 0 R /ImKeep 12 0 R >> >>";
let (pages_res, page_res) = if inherited { (format!("/Resources {res}"), "") } else { (String::new(), "/Resources 13 0 R") };
let page2: &[u8] =
if again { b"q 50 0 0 50 20 20 cm /ImKeep Do Q q 50 0 0 50 120 20 cm /Im1 Do Q" } else { b"q 50 0 0 50 20 20 cm /ImKeep Do Q" };
pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
format!("<< /Type /Pages /Kids [3 0 R 10 0 R] /Count 2 {pages_res} >>").into_bytes(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R {page_res} >>").into_bytes(),
stream("", b"q 50 0 0 50 20 230 cm /Im1 Do Q q 50 0 0 100 100 180 cm /ImPart Do Q /FmSecret Do"),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"REDIMGONECOVERED"),
stream("/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", b"REDIMGPARTSECRETPUBPARTBOTTOMROW"),
stream(
"/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >>",
b"BT /F1 10 Tf 20 230 Td (REDFORMSECRETTEXT) Tj ET",
),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 11 0 R {page_res} >>").into_bytes(),
stream("", page2),
stream("/Type /XObject /Subtype /Image /Width 17 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGKEEPSURVIVE"),
// 13: the dictionary both pages refer to (unused when the resources are inherited).
if inherited { b"<< >>".to_vec() } else { res.as_bytes().to_vec() },
])
}
/// The samples of the image a page's (possibly inherited) resources name `name`.
fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option<Vec<u8>> {
let p = pdfcraft_model::pages(doc).swap_remove(page);
let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?;
let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?;
match &*doc.get(xo.get(name)?.as_ref()?) {
Object::Stream(s) => s.decoded().ok(),
_ => None,
}
}
/// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through
/// before (inherited from the /Pages node, or shared with another page) still named the removed
/// image and form, so a full-rewrite save kept them.
#[test]
fn redacted_xobjects_do_not_survive_through_inherited_or_shared_resources() {
for inherited in [true, false] {
let mut doc = two_pages_one_resource_dictionary(inherited, false);
mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], "");
let r = apply(&mut doc, None).unwrap();
assert!(r.images_removed >= 1 && r.forms_rewritten >= 1, "{r:?}");
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
for secret in [&b"REDIMGONECOVERED"[..], b"REDIMGPARTSECRET", b"REDFORMSECRETTEXT"] {
assert!(!has(secret), "{} survived the save (inherited: {inherited})", String::from_utf8_lossy(secret));
}
// Page 2 still draws its own image through the same dictionary.
let saved = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(named_image(&saved, 1, b"ImKeep").as_deref(), Some(&b"PUBIMGKEEPSURVIVE"[..]), "inherited: {inherited}");
}
}
/// The same image shown on another page is not under a mark there: that page keeps it.
#[test]
fn an_image_another_page_still_draws_through_the_same_resources_is_kept() {
for inherited in [true, false] {
let mut doc = two_pages_one_resource_dictionary(inherited, true);
mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], "");
apply(&mut doc, None).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(named_image(&saved, 1, b"Im1").as_deref(), Some(&b"REDIMGONECOVERED"[..]), "inherited: {inherited}");
assert_eq!(named_image(&saved, 0, b"Im1"), None, "the redacted page no longer names it (inherited: {inherited})");
assert!(content(&saved, 1).contains("/Im1 Do"), "page 2 is untouched");
}
}
/// A form without /Resources draws through its caller's: the image it drew under the mark is
/// named by the page's own dictionary, which no `Do` of the page itself ever used.
#[test]
fn an_image_a_form_drew_through_the_pages_resources_does_not_survive() {
let mut doc = one_page(
b"/FmBare Do",
"/XObject << /FmBare 7 0 R /Im1 8 0 R >>",
vec![
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"q 50 0 0 50 20 230 cm /Im1 Do Q"),
stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"REDIMGONECOVERED"),
],
);
mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!(r.images_removed, 1, "{r:?}");
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(!bytes.windows(16).any(|w| w == b"REDIMGONECOVERED"), "the image the form drew survived the save");
}
/// Remove Hidden Information has the same rewrite: an off layer's image named by an inherited
/// /Resources must leave the saved file too.
#[test]
fn a_hidden_layers_image_does_not_survive_through_inherited_resources() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /OFF [6 0 R] >> >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /Resources << /XObject << /ImOC 5 0 R >> >> >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(),
stream("", b"q 50 0 0 50 20 20 cm /ImOC Do Q"),
stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /OC 6 0 R", b"HIDDENLAYERIMAGE"),
b"<< /Type /OCG /Name (Secret layer) >>".to_vec(),
]);
let done = remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
assert!(done.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{done:?}");
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(!bytes.windows(16).any(|w| w == b"HIDDENLAYERIMAGE"), "the off layer's image survived the save");
}
#[test]
fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() {
// A static AcroForm+XFA form whose field value lives both in the widget /V and in the XFA
@ -726,3 +844,99 @@ fn form_tags_are_cleaned_and_surviving_references_point_at_the_copy() {
assert!(String::from_utf8(s.decoded().unwrap()).unwrap().contains("Public"));
assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText"));
}
#[test]
fn inherited_resources_do_not_keep_the_original_form() {
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (LEAK_INHERITED) Tj ET");
let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]);
let resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().clone();
doc.update_dict(ObjRef::new(2, 0), |d| d.set(b"Resources".to_vec(), resources)).unwrap();
doc.update_dict(ObjRef::new(3, 0), |d| {
d.remove(b"Resources");
})
.unwrap();
mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], "");
apply(&mut doc, None).unwrap();
assert_no_secret(&doc, b"LEAK_");
assert!(!doc.get(ObjRef::new(2, 0)).as_dict().unwrap().contains(b"Resources"));
}
#[test]
fn unselected_pages_keep_inherited_forms() {
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET");
let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]);
let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone();
let resources = page.remove(b"Resources").unwrap();
let second = doc.add(Object::Dict(page));
doc.update_dict(ObjRef::new(2, 0), |d| {
d.set(b"Resources".to_vec(), resources);
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)]));
d.set(b"Count".to_vec(), Object::Int(2));
})
.unwrap();
doc.update_dict(ObjRef::new(3, 0), |d| {
d.remove(b"Resources");
})
.unwrap();
mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], "");
apply(&mut doc, Some(&[0])).unwrap();
let mut saved = reopen(&doc);
assert_eq!(under(&mut saved, 0, &[[0.0, 190.0, 300.0, 220.0]]), 0);
assert_eq!(under(&mut saved, 1, &[[0.0, 190.0, 300.0, 220.0]]), 6);
}
/// A page tree of `n` pages that all draw form /Fm and inherit a direct `/Resources` from the root.
fn inheriting_pages(n: usize) -> (Document, Vec<ObjRef>) {
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET");
let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]);
let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone();
let resources = page.remove(b"Resources").unwrap();
doc.set(ObjRef::new(3, 0), Object::Dict(page.clone()));
let mut kids = vec![ObjRef::new(3, 0)];
kids.extend((1..n).map(|_| doc.add(Object::Dict(page.clone()))));
doc.update_dict(ObjRef::new(2, 0), |d| {
d.set(b"Resources".to_vec(), resources);
d.set(b"Kids".to_vec(), Object::Array(kids.iter().map(|r| Object::Ref(*r)).collect()));
d.set(b"Count".to_vec(), Object::Int(n as i64));
})
.unwrap();
let saved = reopen(&doc);
(saved, kids)
}
#[test]
fn redacting_one_page_leaves_the_other_pages_untouched() {
let (mut doc, kids) = inheriting_pages(50);
mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], "");
let before = doc.modified_objects();
apply(&mut doc, Some(&[0])).unwrap();
let changed: Vec<u32> = doc.modified_objects().into_iter().filter(|n| !before.contains(n)).collect();
for kid in &kids[1..] {
assert!(!changed.contains(&kid.num), "unredacted page {kid:?} was rewritten");
}
// The page, its new content and resources, the cleaned form, the parent and one shared
// resources object: not one object per page.
assert!(changed.len() < 10, "{changed:?}");
// The parent keeps the resources the other pages draw with, through a single shared object.
let parent = doc.get(ObjRef::new(2, 0)).as_dict().unwrap().clone();
assert!(parent.get(b"Resources").and_then(Object::as_ref).is_some());
let mut saved = reopen(&doc);
assert_eq!(under(&mut saved, 0, &[[0.0, 190.0, 300.0, 220.0]]), 0);
assert_eq!(under(&mut saved, 49, &[[0.0, 190.0, 300.0, 220.0]]), 6);
}
#[test]
fn redacting_every_inheriting_page_retires_the_parent_resources() {
let (mut doc, kids) = inheriting_pages(20);
for page in 0..kids.len() {
mark(&mut doc, page, &[[0.0, 190.0, 300.0, 220.0]], "");
}
apply(&mut doc, None).unwrap();
assert!(!doc.get(ObjRef::new(2, 0)).as_dict().unwrap().contains(b"Resources"));
assert_no_secret(&doc, b"SECRET");
let mut saved = reopen(&doc);
for page in 0..kids.len() {
assert!(pdfcraft_model::pages(&saved)[page].dict.contains(b"Resources"));
assert_eq!(under(&mut saved, page, &[[0.0, 190.0, 300.0, 220.0]]), 0);
}
}

View File

@ -365,6 +365,13 @@ pub struct FontInfo {
pub embedded: bool,
pub subset: bool,
pub encoding: Option<String>,
/// The face standing in for a program the document does not embed. `None` when the font is
/// embedded (nothing stands in) or when no face was available at all -- `substitution` then
/// says which of the two it is.
pub substitute: Option<String>,
/// Why a face was substituted, as a sentence for whoever has to act on it. `None` only when
/// the document's own glyphs are being shown.
pub substitution: Option<String>,
}
/// Check that `bytes` still open as the same document: the parser reads them and the page
@ -1290,20 +1297,38 @@ impl<'a> Inspector<'a> {
_ => None,
};
// Embedded: FontFile/2/3 in the descriptor (for Type0, in the descendant font).
let descriptor_of = |d: &Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o)).cloned();
let desc = descriptor_of(f).or_else(|| {
f.get(b"DescendantFonts")
.ok()
.and_then(|o| self.resolve(o).as_array().ok())
.and_then(|a| a.first())
.and_then(|o| self.dict(o))
.and_then(descriptor_of)
});
let descriptor_of = |d: &'a Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o));
let descendant =
f.get(b"DescendantFonts").ok().and_then(|o| self.resolve(o).as_array().ok()).and_then(|a| a.first()).and_then(|o| self.dict(o));
let desc = descriptor_of(f).or_else(|| descendant.and_then(descriptor_of));
let embedded =
kind == "Type3" || desc.is_some_and(|d| d.get(b"FontFile").is_ok() || d.get(b"FontFile2").is_ok() || d.get(b"FontFile3").is_ok());
// What the substitution chain needs beyond the name: the descriptor flags, and for
// a composite font the script its descendant declares (ISO 32000-2 9.7.3).
let flags = desc
.and_then(|d| d.get(b"Flags").ok())
.and_then(|o| self.resolve(o).as_i64().ok())
.and_then(|n| u32::try_from(n).ok())
.unwrap_or(0);
let ordering =
descendant.and_then(|d| d.get(b"CIDSystemInfo").ok()).and_then(|o| self.dict(o)).and_then(|c| self.text(c, b"Ordering"));
let subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase());
let name = if subset { base[7..].to_string() } else { base };
let info = FontInfo { name, kind, embedded, subset, encoding };
let chosen = pdfcraft_fonts::fallback::substitute(&pdfcraft_fonts::fallback::Request {
base_font: &name,
embedded,
flags,
ordering: ordering.as_deref(),
});
let info = FontInfo {
name,
kind,
embedded,
subset,
encoding,
substitute: chosen.face_name(),
substitution: (!chosen.why.is_faithful()).then(|| chosen.why.note().to_owned()),
};
if seen.insert((info.name.clone(), info.kind.clone(), info.embedded)) {
out.push(info);
}
@ -1981,7 +2006,20 @@ trailer << /Root 1 0 R >>
let expected: &[u8] = if a.name == "notes.txt" { b"hello notes" } else { b"a,b,c" };
assert_eq!(data, expected, "{}", a.name);
}
assert_eq!(info.fonts, vec![FontInfo { name: "Helvetica".into(), kind: "Type1".into(), embedded: false, subset: true, encoding: None }]);
// A non-embedded /Helvetica is reported as standing in for itself: the standard-14
// metrics are exact, so the substitution is named rather than left implicit.
assert_eq!(
info.fonts,
vec![FontInfo {
name: "Helvetica".into(),
kind: "Type1".into(),
embedded: false,
subset: true,
encoding: None,
substitute: Some("Helvetica".into()),
substitution: Some(pdfcraft_fonts::fallback::Why::NamedStandard14.note().into()),
}]
);
}
const LAYERS: &[u8] = b"%PDF-1.7
@ -2520,3 +2558,97 @@ mod version_tests {
assert_eq!(super::version_label("Unknown"), "Unknown");
}
}
#[cfg(test)]
mod substitution_tests {
use std::sync::Arc;
use super::{FontInfo, inspect};
/// One page naming four fonts the document does not embed: a Chinese CID font (Adobe-GB1), a
/// Japanese one (Adobe-Japan1), an unknown serif-flagged simple font, and an aliased
/// standard-14 name.
const NOT_EMBEDDED: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R /F2 6 0 R /F3 8 0 R /F4 9 0 R /F5 11 0 R >> >> >> endobj
4 0 obj << /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [5 0 R] >> endobj
5 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> >> endobj
6 0 obj << /Type /Font /Subtype /Type0 /BaseFont /KozMinPro-Regular /Encoding /UniJIS-UCS2-H /DescendantFonts [7 0 R] >> endobj
7 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /KozMinPro-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 4 >> >> endobj
8 0 obj << /Type /Font /Subtype /TrueType /BaseFont /SomeHouseSerif /FontDescriptor 10 0 R >> endobj
9 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ArialMT >> endobj
10 0 obj << /Type /FontDescriptor /FontName /SomeHouseSerif /Flags 34 >> endobj
11 0 obj << /Type /Font /Subtype /TrueType /BaseFont /HouseGrotesk /FontDescriptor 12 0 R >> endobj
12 0 obj << /Type /FontDescriptor /FontName /HouseGrotesk /Flags 33 >> endobj
trailer << /Size 13 /Root 1 0 R >>
%%EOF";
fn font<'a>(fonts: &'a [FontInfo], name: &str) -> &'a FontInfo {
fonts.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name} listed; got {:?}", fonts.iter().map(|f| &f.name).collect::<Vec<_>>()))
}
#[test]
fn doc_info_says_which_face_stands_in_and_why() {
let info = inspect(Arc::new(NOT_EMBEDDED.to_vec()), None).expect("opens");
// The one that matters most to a user: a Chinese PDF that draws nothing. Before this,
// doc_info said only `embedded: false` and left them to guess.
let cjk = font(&info.fonts, "STSong-Light");
assert!(!cjk.embedded);
assert_eq!(cjk.substitute, None, "no licensed Chinese face, so nothing stands in");
let why = cjk.substitution.as_deref().unwrap_or_default();
assert!(why.contains("no licensed face") && why.contains("rather than the wrong glyphs"), "{why}");
// An aliased standard-14 name resolves to the real face, whose metrics are exact.
let arial = font(&info.fonts, "ArialMT");
assert_eq!(arial.substitute.as_deref(), Some("Helvetica"));
assert!(arial.substitution.as_deref().unwrap_or_default().contains("standard 14"));
// A house font whose name merely contains "Serif" gets Times -- reported as the guess it
// is, not as a standard-14 name, which would be untrue of a font nobody has heard of.
let serif = font(&info.fonts, "SomeHouseSerif");
assert_eq!(serif.substitute.as_deref(), Some("Times-Roman"));
assert!(serif.substitution.as_deref().unwrap_or_default().contains("hints at one"), "{serif:?}");
// No hint in the name at all, so the descriptor flags decide: 33 = fixed pitch + nonsymbolic.
let grotesk = font(&info.fonts, "HouseGrotesk");
assert_eq!(grotesk.substitute.as_deref(), Some("Courier"));
assert!(grotesk.substitution.as_deref().unwrap_or_default().contains("descriptor flags"), "{grotesk:?}");
// Japanese depends on the optional craft-fonts build input; either way it is reported,
// and never as a Latin face.
let jpan = font(&info.fonts, "KozMinPro-Regular");
assert!(jpan.substitution.is_some(), "a substitution is always explained");
match &jpan.substitute {
Some(face) => {
assert!(!face.starts_with("Helvetica") && !face.starts_with("Times"), "a Latin face is never a Japanese substitute: {face}")
}
None => assert!(jpan.substitution.as_deref().unwrap_or_default().contains("no licensed face")),
}
// Nothing claims to be embedded, so every font here carries a reason.
assert!(info.fonts.iter().all(|f| !f.embedded && f.substitution.is_some()), "{:?}", info.fonts);
}
/// An embedded font is reported as faithful: nothing stands in, and there is nothing to explain.
const EMBEDDED: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R >> >> >> endobj
4 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+SomeHouseSans /FontDescriptor 5 0 R >> endobj
5 0 obj << /Type /FontDescriptor /FontName /ABCDEF+SomeHouseSans /Flags 32 /FontFile2 6 0 R >> endobj
6 0 obj << /Length 4 >> stream
test
endstream endobj
trailer << /Size 7 /Root 1 0 R >>
%%EOF";
#[test]
fn an_embedded_font_reports_no_substitution_at_all() {
let info = inspect(Arc::new(EMBEDDED.to_vec()), None).expect("opens");
let f = font(&info.fonts, "SomeHouseSans");
assert!(f.embedded && f.subset, "{f:?}");
assert_eq!((f.substitute.as_deref(), f.substitution.as_deref()), (None, None), "the document's own glyphs are shown");
}
}

View File

@ -0,0 +1,113 @@
//! Independent flat-patch oracles for the Lab Bradford follow-up to #1237.
use crate::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
use std::sync::Arc;
// These frozen RGB8 values were computed outside the renderer in f64 from the
// CIE Lab inverse, the full XYZ adaptation matrix B^-1 diag(B*D65 / B*white) B,
// and the standard sRGB matrix/transfer function. No production helper is used.
// Bradford reference: https://www.color.org/chadtag/ (ICC TN 02-2003).
// White points: D50 [0.96422, 1, 0.82521], E [1, 1, 1],
// D65 [0.95047, 1, 1.08883]. D65 adaptation is the identity.
const SAMPLES: [[f64; 3]; 4] = [[50.0, 0.0, 0.0], [40.0, 20.0, -60.0], [70.0, -50.0, 30.0], [100.0, 0.0, 0.0]];
const D50: [[u8; 3]; 4] = [[119, 119, 119], [67, 86, 194], [66, 194, 113], [255, 255, 255]];
const E: [[u8; 3]; 4] = [[119, 119, 119], [50, 88, 194], [64, 194, 114], [255, 255, 255]];
const D65: [[u8; 3]; 4] = [[119, 119, 119], [19, 90, 194], [77, 193, 114], [255, 255, 255]];
fn patch(space: &str, operands: &str) -> [u8; 4] {
let content = format!("/C cs {operands} sc 0 0 20 20 re f");
let bytes = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] /Resources << /ColorSpace << /C {space} >> >> /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
content.len()
)
.into_bytes();
let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
page: 0,
kind: RequestKind::Pixels,
scale: 1.0,
..Default::default()
});
assert!(page.error.is_none(), "{space} {operands}: {:?}", page.error);
assert_eq!((page.width, page.height), (20, 20));
page.rgba.get((10 * 20 + 10) * 4..(10 * 20 + 10) * 4 + 4).unwrap().try_into().unwrap()
}
fn assert_rgb(actual: [u8; 4], expected: [u8; 3], context: &str) {
assert_eq!(actual[3], 255, "{context}: {actual:?}");
for (got, want) in actual.into_iter().zip(expected) {
assert!(got.abs_diff(want) <= 1, "{context}: got {actual:?}, expected {expected:?}");
}
}
fn check_white(white: &str, expected: [[u8; 3]; 4]) {
let space = format!("[/Lab << /WhitePoint {white} /Range [-100 100 -100 100] >>]");
for (sample, expected) in SAMPLES.into_iter().zip(expected) {
let operands = format!("{} {} {}", sample[0], sample[1], sample[2]);
assert_rgb(patch(&space, &operands), expected, &format!("{white} Lab {sample:?}"));
}
}
#[test]
fn lab_bradford_d50_neutral_and_chromatic_oracles() {
check_white("[0.96422 1 0.82521]", D50);
}
#[test]
fn lab_bradford_equal_energy_neutral_and_chromatic_oracles() {
check_white("[1 1 1]", E);
}
#[test]
fn lab_bradford_d65_identity_oracles() {
check_white("[0.95047 1 1.08883]", D65);
}
#[test]
fn lab_bradford_indexed_matches_direct_and_oracle() {
for (white, blue) in [("[0.96422 1 0.82521]", D50[1]), ("[1 1 1]", E[1]), ("[0.95047 1 1.08883]", D65[1])] {
// <66 99 33> maps exactly to L*=40, a*=20, b*=-60.
let lab = format!("[/Lab << /WhitePoint {white} /Range [-100 100 -100 100] >>]");
let indexed = patch(&format!("[/Indexed {lab} 0 <669933>]"), "0");
assert_rgb(indexed, blue, &format!("{white} Indexed blue"));
assert_eq!(indexed, patch(&lab, "40 20 -60"), "{white} direct/Indexed blue");
// A non-default Range maps <80 80 80> to L*=128/255*100, a*=b*=0.
let lab = format!("[/Lab << /WhitePoint {white} /Range [-128 127 -128 127] >>]");
let indexed = patch(&format!("[/Indexed {lab} 0 <808080>]"), "0");
assert_rgb(indexed, [119, 119, 119], &format!("{white} Indexed neutral"));
assert_eq!(indexed, patch(&lab, "50.19607843137255 0 0"), "{white} direct/Indexed neutral");
}
}
#[test]
fn lab_bradford_degenerate_cones_fall_back_to_unadapted_xyz() {
// In f32, B*[1, 1, 7.1964064] has a zero L cone response.
// B*[3e38, 3e38, 3e38] overflows its L/M responses. The guard must
// return the sample XYZ in both cases. Independent unadapted XYZ ->
// sRGB references for Lab (50, 0, 0) are frozen below.
for (white, expected) in [
("[1 1 7.1964064]", [0, 130, 255]),
(
"[300000000000000000000000000000000000000 300000000000000000000000000000000000000 300000000000000000000000000000000000000]",
[255, 255, 255],
),
] {
let space = format!("[/Lab << /WhitePoint {white} >>]");
assert_rgb(patch(&space, "50 0 0"), expected, &format!("{white} degenerate cones"));
}
}
#[test]
fn lab_bradford_asymmetric_chromatic_range_matches_direct_and_oracle() {
// With a* [-40, 70] and b* [-90, 20], <80 99 33> maps to
// L*=128/255*100, a*=26, b*=-68. Frozen independent D50 oracle:
// RGB (98, 108, 237). The unequal, asymmetric ranges expose swaps
// and default-Range scaling that a neutral lookup cannot detect.
let lab = "[/Lab << /WhitePoint [0.96422 1 0.82521] /Range [-40 70 -90 20] >>]";
let indexed = patch(&format!("[/Indexed {lab} 0 <809933>]"), "0");
assert_rgb(indexed, [98, 108, 237], "D50 asymmetric chromatic Range");
assert_eq!(indexed, patch(lab, "50.19607843137255 26 -68"));
}

View File

@ -39,3 +39,6 @@ pub enum OpenError {
#[error("{0}")]
Unsupported(String),
}
#[cfg(test)]
mod lab_bradford_tests;

View File

@ -3472,6 +3472,78 @@ trailer << /Root 1 0 R >>
page(&format!("q 20 0 0 20 5 5 cm /Im1 Do Q {red}"), fax);
}
#[test]
fn tiff_predictor_packed_rgb_rows_keep_their_byte_boundaries() {
use hayro_syntax::object::{FromBytes, Object};
use std::io::Write;
// Red then green, with only the real sample bits compared: row padding may change.
// The three-column case also checks horizontal differencing: red/green/blue and
// green/red/blue, whose one-bit component differences wrap modulo two.
struct RowCase {
bpc: u8,
columns: u32,
encoded: &'static [u8],
expected: &'static [u8],
masks: &'static [u8],
}
let cases = [
RowCase { bpc: 1, columns: 1, encoded: &[0x80, 0x40], expected: &[0x80, 0x40], masks: &[0xe0] },
RowCase { bpc: 2, columns: 1, encoded: &[0xc0, 0x30], expected: &[0xc0, 0x30], masks: &[0xfc] },
RowCase { bpc: 4, columns: 1, encoded: &[0xf0, 0, 0x0f, 0], expected: &[0xf0, 0, 0x0f, 0], masks: &[0xff, 0xf0] },
RowCase { bpc: 1, columns: 3, encoded: &[0x99, 0x80, 0x5a, 0x80], expected: &[0x88, 0x80, 0x50, 0x80], masks: &[0xff, 0x80] },
RowCase { bpc: 8, columns: 1, encoded: &[255, 0, 0, 0, 255, 0], expected: &[255, 0, 0, 0, 255, 0], masks: &[0xff, 0xff, 0xff] },
];
for RowCase { bpc, columns, encoded, expected, masks } in cases {
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(encoded).unwrap();
let compressed = encoder.finish().unwrap();
let mut bytes = format!(
"<< /Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 3 /BitsPerComponent {bpc} /Columns {columns} >> /Length {} >> stream\n",
compressed.len()
)
.into_bytes();
bytes.extend_from_slice(&compressed);
bytes.extend_from_slice(b"\nendstream");
let Object::Stream(stream) = Object::from_bytes(&bytes).unwrap() else { panic!("stream expected") };
let decoded = stream.decoded().unwrap();
assert_eq!(decoded.len(), expected.len());
for ((actual, expected), mask) in decoded.iter().zip(expected).zip(masks.iter().cycle()) {
assert_eq!(actual & mask, *expected, "{bpc}-bit RGB, {columns} columns");
}
}
}
#[test]
fn tiff_predictor_packed_rgb_image_renders_every_row() {
use std::io::Write;
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(&[0x80, 0x40]).unwrap();
let compressed = encoder.finish().unwrap();
let content = "q 40 0 0 40 0 0 cm /Im1 Do Q";
let mut pdf = format!(
"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 1 /Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 3 /BitsPerComponent 1 /Columns 1 >> /Length {} >> stream\n",
content.len(),
compressed.len()
)
.into_bytes();
pdf.extend_from_slice(&compressed);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (40, 40));
let pixel = |y: usize| &page.rgba[((y * 40 + 20) * 4)..][..4];
assert_eq!(pixel(10), &[255, 0, 0, 255]);
assert_eq!(pixel(30), &[0, 255, 0, 255]);
}
/// From the nightly `cargo xtask fuzz`: a FlateDecode predictor with `/Columns
/// 9223372036854775807` wrapped to a 2^61-byte row allocation (an abort on any machine), and
/// a line width of 9223372036854775807 made stroke expansion allocate 10 GB. Vendored hayro
@ -4827,6 +4899,37 @@ trailer << /Root 1 0 R >>
assert_eq!(page.rgba.as_chunks::<4>().0.to_vec(), expected);
}
/// Issue #1649: Word can save text as a tiny black image whose `/SMask` is a high-resolution
/// bitmap of the glyphs, so the mask alone carries the text. Shrinking that mask by
/// nearest-neighbour keeps one sample in N, and a stroke thinner than N mask pixels vanished:
/// the text came out broken and aliased. Here a one-column stroke of an 8-column mask is drawn
/// 4 pixels wide; nearest reads columns 1, 3, 5 and 7 and misses the stroke in column 2.
#[test]
fn shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes() {
let mask: [u8; 8] = [0, 0, 255, 0, 0, 0, 0, 0];
let content = "q 4 0 0 1 0 0 cm /Im0 Do Q";
let pdf = format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 4 1] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R /Filter /ASCIIHexDecode /Length 3 >> stream\n00>\nendstream endobj\n\
6 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{}>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF\n",
content.len(),
hex(&mask).len() + 1,
hex(&mask)
);
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (4, 1));
let grays: Vec<u8> = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect();
// The stroke covers half of device pixel 1, so it is a mid-tone; the far side stays white.
assert!((64..=191).contains(&grays[1]), "the stroke is lost or not a mid-tone: {grays:?}");
assert_eq!(grays[3], 255, "{grays:?}");
}
/// Type 3 glyph images used to be sampled bicubically (`ImageQuality::High`) whatever their
/// `/Interpolate`, which blends the nearest pre-resize back in. The glyph is 4.6 × 2.3 pixels
/// and its image pre-resizes to 5 × 3, so the final draw samples fractionally and bicubic
@ -5885,4 +5988,46 @@ trailer << /Root 1 0 R >>
assert!([(5, 5), (50, 50), (95, 95)].iter().all(|(x, y)| px(&p, *x, *y) == red), "{content}: no stray clip");
}
}
/// ISO 32000-1 §9.3.6: only text rendering mode 3 has any effect on a Type 3 font (vendored
/// hayro-interpret patch in `show_glyph`). Print-stream PDFs set `2 Tr` for Type 3 bitmap
/// fonts, and the glyph procedure ran twice (fill, then stroke), so its anti-aliased edges
/// doubled up and the text looked bold; under `1 Tr` an uncoloured glyph took the stroking
/// colour, and under `7 Tr` it disappeared. Both glyph kinds have edges between pixels.
#[test]
fn text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled() {
let render = |glyph: &str, mode: u8| {
let content = format!("BT 1 0 0 RG 6 w 0 g /F1 20 Tf {mode} Tr 10.3 10.3 Td (A) Tj ET");
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [1]
/Encoding << /Differences [65 /a] >> /CharProcs << /a 6 0 R >> >> endobj
6 0 obj << /Length {} >> stream
{glyph}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
glyph.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{glyph} {mode} Tr: {:?}", p.error);
p.rgba
};
for glyph in ["1 0 0 0 1 1 d1 0.12 0.12 0.71 0.71 re f", "1 0 d0 0.12 0.12 0.71 0.71 re f"] {
let filled = render(glyph, 0);
assert!(filled.as_chunks::<4>().0.iter().any(|p| p[0] < 128), "{glyph}: the glyph paints");
for mode in [1, 2, 4, 5, 6, 7] {
assert!(render(glyph, mode) == filled, "{glyph}: {mode} Tr draws as 0 Tr");
}
assert!(render(glyph, 3).as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255]), "{glyph}: 3 Tr hides the glyph");
}
}
}

View File

@ -149,18 +149,32 @@ impl PageText {
/// separated by one space, original case). Used for pattern search (Search & Redact).
pub fn find_with(&self, matcher: impl Fn(&[char]) -> Vec<std::ops::Range<usize>>) -> Vec<std::ops::Range<usize>> {
let mut chars: Vec<char> = Vec::new();
let mut owner: Vec<usize> = Vec::new();
// `None` is a word or line separator inserted between glyphs. It is not part of either
// glyph, so a match that starts or ends on it must not take the neighbouring glyph.
let mut owner: Vec<Option<usize>> = Vec::new();
for (i, g) in self.glyphs.iter().enumerate() {
if i > 0 && (self.space_before[i] || self.line_of[i] != self.line_of[i - 1]) && chars.last().is_some_and(|c| *c != ' ') {
let gap = self.space_before.get(i).copied().unwrap_or(false) || self.line_of.get(i) != self.line_of.get(i.saturating_sub(1));
if i > 0 && gap && chars.last().is_some_and(|c| *c != ' ') {
chars.push(' ');
owner.push(i);
owner.push(None);
}
for c in g.text.chars() {
chars.push(if c.is_whitespace() { ' ' } else { c });
owner.push(i);
owner.push(Some(i));
}
}
matcher(&chars).into_iter().filter(|r| r.start < r.end && r.end <= chars.len()).map(|r| owner[r.start]..owner[r.end - 1] + 1).collect()
matcher(&chars)
.into_iter()
.filter_map(|r| {
if r.start >= r.end || r.end > chars.len() {
return None;
}
let glyphs = owner.get(r.start..r.end)?;
let start = glyphs.iter().find_map(|g| *g)?;
let end = glyphs.iter().rev().find_map(|g| *g)?.checked_add(1)?;
(start < end).then_some(start..end)
})
.collect()
}
/// Index of the glyph nearest to a view-space point (for selection anchors).
@ -1276,14 +1290,18 @@ pub fn layout(glyphs: Vec<TextGlyph>) -> PageText {
/// [`layout`] as the reading order of indices into `glyphs`, with each one's line and word gap.
fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
// Drop "fake bold" duplicates: the same character redrawn at (almost) the same place.
// Drop "fake bold" duplicates: the same character redrawn at (almost) the same place. A redraw
// also overlaps most of its own width: a narrow letter in condensed text (80 Tz and below) sits
// closer than a fifth of its height to its double, as the second "l" of "Alle" (#1467).
let mut keep = vec![true; glyphs.len()];
for i in 1..glyphs.len() {
let g = &glyphs[i];
let h = (g.rect[3] - g.rect[1]).max(0.1);
let w = g.rect[2] - g.rect[0];
let dx = if w > 0.05 * h { (h * 0.2).min(w * 0.5) } else { h * 0.2 };
for j in (i.saturating_sub(4)..i).rev() {
let p = &glyphs[j];
if keep[j] && p.text == g.text && (p.rect[0] - g.rect[0]).abs() < h * 0.2 && (p.rect[1] - g.rect[1]).abs() < h * 0.2 {
if keep[j] && p.text == g.text && (p.rect[0] - g.rect[0]).abs() < dx && (p.rect[1] - g.rect[1]).abs() < h * 0.2 {
keep[i] = false;
break;
}
@ -1853,6 +1871,36 @@ mod tests {
assert_eq!(t.nearest(52.0, 14.0), Some(5));
}
/// #1315: a custom match that ends on a reconstructed word or line separator does not take
/// the following glyph.
#[test]
fn a_match_ending_on_a_separator_stops_before_the_next_glyph() {
let glyphs: Vec<TextGlyph> = "quickbrown"
.chars()
.enumerate()
.map(|(i, c)| {
let x = i as f32 * 6.0;
g(&c.to_string(), x, 0.0, x + 6.0)
})
.collect();
for line_break in [false, true] {
let mut space_before = vec![false; 10];
space_before[5] = !line_break;
let line_of = (0..10).map(|i| u32::from(line_break && i >= 5)).collect();
let page = PageText { glyphs: glyphs.clone(), line_of, space_before };
let plain = if line_break { "quick\nbrown" } else { "quick brown" };
assert_eq!(page.plain_text(), plain);
assert_eq!(page.find("quick "), vec![0..5]);
let span = |r: std::ops::Range<usize>| Vec::from([r]);
assert_eq!(page.find_with(|_| span(0..5)), vec![0..5]);
assert_eq!(page.find_with(|_| span(5..11)), vec![5..10]);
assert_eq!(page.find_with(|_| span(6..11)), vec![5..10]);
let hits = page.find_with(|_| span(0..6));
assert_eq!(page.text_of(hits[0].clone()), "quick");
assert_eq!(hits, vec![0..5]);
}
}
#[test]
fn phrase_search_ignores_repeated_spaces() {
// "quick brown" drawn with two space glyphs in a row. Text extraction keeps both spaces,
@ -1900,6 +1948,29 @@ mod tests {
v.push(g(&c.to_string(), x + 0.3, 10.0, x + 6.3));
}
assert_eq!(layout(v).plain_text(), "Bold");
// A zero-width combining mark redrawn on itself is dropped too.
let v = vec![g("e", 10.0, 10.0, 16.0), g("\u{301}", 13.0, 10.0, 13.0), g("e", 10.0, 10.0, 16.0), g("\u{301}", 13.0, 10.0, 13.0)];
assert_eq!(layout(v).plain_text(), "e\u{301}");
}
/// #1467: condensed text (80 Tz and below, OCR layers fitted to the scan) sets narrow letters
/// closer than a fifth of their height apart; a doubled letter there is two letters, not a redraw.
#[test]
fn condensed_double_letters_are_kept() {
for scale in [0.8, 0.65, 0.5] {
let mut v = Vec::new();
let mut x = 10.0;
// Helvetica advances at 10 pt, in thousandths of an em.
for (c, adv) in [('A', 667.0), ('l', 222.0), ('l', 222.0), ('e', 556.0)] {
let w = adv / 100.0 * scale;
v.push(g(&c.to_string(), x, 10.0, x + w));
x += w;
}
assert_eq!(layout(v.clone()).plain_text(), "Alle", "at {scale} horizontal scaling");
// Each letter redrawn a fifth of a point to the right is still one letter.
let bold: Vec<TextGlyph> = v.iter().flat_map(|c| [c.clone(), g(&c.text, c.rect[0] + 0.2, 10.0, c.rect[2] + 0.2)]).collect();
assert_eq!(layout(bold).plain_text(), "Alle", "fake bold at {scale} horizontal scaling");
}
}
#[test]
@ -2463,4 +2534,43 @@ mod tests {
let all = t.glyphs_in([0.0, 0.0, 60.0, 40.0]);
assert_eq!(t.column_text(&all), "日本\n語文");
}
/// An embedded TrueType font with no `/Encoding`, flagged `flags`, its `/ToUnicode` (if
/// any) mapping only printable ASCII, as OCR engines write the text layer of a scan.
fn ocr_layer_text(flags: i64, ascii_to_unicode: bool) -> String {
use lopdf::{Document, Object, Stream, dictionary};
let mut doc = Document::with_version("1.7");
let program = doc.add_object(Stream::new(dictionary! {}, include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()));
let descriptor =
doc.add_object(dictionary! { "Type" => "FontDescriptor", "FontName" => "Inter-Regular", "Flags" => flags, "FontFile2" => program });
let mut font = dictionary! { "Type" => "Font", "Subtype" => "TrueType", "BaseFont" => "Inter-Regular", "FontDescriptor" => descriptor };
if ascii_to_unicode {
let cmap = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Ascii def /CMapType 2 def 1 begincodespacerange <00> <FF> endcodespacerange 1 beginbfrange <20> <7E> <0020> endbfrange endcmap CMapName currentdict /CMap defineresource pop end end";
font.set("ToUnicode", doc.add_object(Stream::new(dictionary! {}, cmap.to_vec())));
}
let font = doc.add_object(font);
// WinAnsi bytes: "Rückfragen für Überhitzungsschutz: können, weiß, Café".
let mut ops = b"BT /F1 14 Tf 1 0 0 1 20 200 Tm (".to_vec();
ops.extend_from_slice(b"R\xfcckfragen f\xfcr \xdcberhitzungsschutz: k\xf6nnen, wei\xdf, Caf\xe9");
ops.extend_from_slice(b") Tj ET");
let content = doc.add_object(Stream::new(dictionary! {}, ops));
let pages = doc.new_object_id();
let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content });
doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into());
let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages});
doc.trailer.set("Root", catalog);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).unwrap();
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text()
}
#[test]
fn truetype_without_an_encoding_reads_its_codes_as_win_ansi() {
// Café: 0xE9 is /Oslash in StandardEncoding, so reading the codes as Standard first and
// WinAnsi only for the gaps would still give "CafØ".
let expected = "Rückfragen für Überhitzungsschutz: können, weiß, Café";
assert_eq!(ocr_layer_text(4, false), expected, "symbolic, no /ToUnicode");
assert_eq!(ocr_layer_text(32, true), expected, "nonsymbolic, /ToUnicode for ASCII only");
}
}

View File

@ -1220,6 +1220,18 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
_ => false,
}
};
// A later cross-reference section may carry an entry this reader sets aside (an in-use entry
// at offset 0, a second entry for one object, a reserved entry type). The renderer may follow
// it and draw another object than the one compared below, so the page could show new content
// while every comparison here finds the signed object untouched. No writer has a reason to
// add such an entry after signing: it is never a permitted change.
for (_, num) in doc.xref_oddities().iter().filter(|o| !old.xref_oddities().contains(o)) {
let r = ObjRef { num: *num, generation: doc.generation(*num) };
let what = if old_content.contains(&r) || signed_pages.contains(&r) { kind::PAGE_CONTENT } else { kind::OTHER };
if !disallowed.contains(&what) {
disallowed.push(what);
}
}
for num in doc.object_numbers() {
if same_place(num) {
continue;
@ -1228,6 +1240,12 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
let new = doc.get(r);
let before = old.try_get(num).ok().filter(|o| !matches!(**o, Object::Null));
if before.as_deref() == Some(&*new) {
// The same content at a new place. That is true for the renderer as well only when
// the entry locates the object itself: where this reader had to search for the
// object, another reader may arrive at a different one.
if !doc.is_edited(num) && !doc.xref_entry_is_exact(num) && !disallowed.contains(&kind::OTHER) {
disallowed.push(kind::OTHER);
}
continue;
}
let what = match &*new {

View File

@ -1900,3 +1900,81 @@ fn details_say_themselves() {
assert_eq!(holes, wanted, "{:?} has {holes} holes", d.pattern);
}
}
/// `signed` with one more revision appended by hand: a new definition of object 4 (the page's
/// content stream) that draws `text`, then a cross-reference table section whose subsections are
/// what `xref` returns for the offset of that definition. The section continues the signed chain.
fn append_content_revision(signed: &[u8], text: &str, xref: impl Fn(usize) -> String) -> Vec<u8> {
let at = signed.windows(9).rposition(|w| w == b"startxref").unwrap() + 9;
let prev: String = signed[at..].iter().skip_while(|b| b.is_ascii_whitespace()).take_while(|b| b.is_ascii_digit()).map(|b| *b as char).collect();
let mut out = signed.to_vec();
let content = format!("BT /F1 14 Tf 20 250 Td ({text}) Tj ET");
let object = out.len();
out.extend_from_slice(format!("4 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\n", content.len()).as_bytes());
let section = out.len();
out.extend_from_slice(
format!("xref\n{}trailer\n<< /Size 60 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n", xref(object)).as_bytes(),
);
out
}
fn xref_entry(offset: usize) -> String {
format!("{offset:010} 00000 n \n")
}
/// The reader that judges changes and the renderer that draws the page resolve some
/// cross-reference entries differently. A revision appended after signing can redefine the page's
/// content and give the object an entry the reader sets aside (an in-use entry at offset 0, or
/// a second entry after one that still names the signed object): the reader then keeps comparing
/// the signed object with itself while the page shows the new one. Such an entry is never a
/// permitted change.
#[test]
fn a_cross_reference_entry_the_reader_sets_aside_does_not_hide_new_page_content() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
for certify in LEVELS {
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { certify, ..opts() }).unwrap();
// Where the signed revision keeps the page's content stream.
let signed_at = signed.windows(8).position(|w| w == b"\n4 0 obj").unwrap() + 1;
// Each writes the section's subsections, given where the new object 4 was put.
type Subsections = Box<dyn Fn(usize) -> String>;
let attacks: [(&str, Subsections); 3] = [
("an in-use entry at offset 0", Box::new(|_| format!("4 1\n{}", xref_entry(0)))),
("a second entry for the object", Box::new(move |new| format!("4 1\n{}4 1\n{}", xref_entry(signed_at), xref_entry(new)))),
// The plain case, which was always caught: one entry naming the new object.
("an ordinary entry", Box::new(|new| format!("4 1\n{}", xref_entry(new)))),
];
for (name, xref) in attacks {
let edited = append_content_revision(&signed, "Forged text", xref);
let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Invalid, "{certify:?}, {name}: {:?}", s.details);
assert!(
matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"page content")),
"{certify:?}, {name}: {:?}",
s.modification
);
}
}
}
/// An entry that does not point at the object it names leaves the reader to find the object by
/// searching the file. What it finds may equal the signed object, yet another reader may search
/// differently: the object cannot be called unchanged.
#[test]
fn a_cross_reference_entry_that_misses_its_object_is_not_an_unchanged_object() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
// A copy of the signed content, named by an entry two bytes into its `4 0 obj` header.
let edited = append_content_revision(&signed, "Contract text", |new| format!("4 1\n{}", xref_entry(new + 2)));
let doc = open(&edited);
assert!(!doc.xref_entry_is_exact(4) && doc.xref_entry_is_exact(3));
let s = signatures(&doc, &edited, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"other changes")), "{:?}", s.modification);
// The same copy under an entry that does point at it is the unchanged object it looks like.
let honest = append_content_revision(&signed, "Contract text", |new| format!("4 1\n{}", xref_entry(new)));
let s = signatures(&open(&honest), &honest, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
}

View File

@ -48,6 +48,9 @@ skrifa = "0.44"
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSEvent", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSGeometry"] }
# A plain fsync where a volume refuses the full flush `sync_all` asks for (SMB, FUSE); already in
# the tree through pdfcraft-cli.
rustix = { version = "1", features = ["fs"] }
[target.'cfg(windows)'.dependencies]
# Auto interface language: the Windows display language, without starting a process.

View File

@ -357,14 +357,18 @@ impl PdfCraftApp {
let Some((_, id)) = self.active_ids() else { return };
let Some(doc) = self.session.get(id) else { return };
let o = &self.a11y_options;
let options = Options { rules: o.rules.clone(), pages: (!o.all_pages).then(|| (o.from.max(1) - 1..o.to.max(o.from)).collect()) };
// The range was picked on whatever document had the options dialog last; with the dialog
// turned off it reaches here unchecked, and a page past the end would select nothing.
let to = o.to.clamp(1, doc.info.pages.len().max(1));
let from = o.from.clamp(1, to);
let options = Options { rules: o.rules.clone(), pages: (!o.all_pages).then(|| (from - 1..to).collect()) };
let Some(report) = doc.accessibility_check(&options) else {
self.notify_tr("This document can't be checked: it couldn't be read for editing");
return;
};
// Rules skipped by hand stay skipped when checking again.
let mut report = report;
for r in report.results.iter_mut().filter(|r| self.a11y_skipped.contains(&r.rule)) {
for r in report.results.iter_mut().filter(|r| self.a11y_skipped.contains(&(id, r.rule))) {
r.status = Status::Skipped;
r.findings.clear();
}
@ -448,12 +452,12 @@ impl PdfCraftApp {
}
}
PanelAction::Skip(rule) => {
self.a11y_skipped.insert(rule);
if let Some((_, r)) = self.a11y.report.as_mut()
&& let Some(x) = r.results.iter_mut().find(|x| x.rule == rule)
{
x.status = Status::Skipped;
x.findings.clear();
if let Some((id, r)) = self.a11y.report.as_mut() {
self.a11y_skipped.insert((*id, rule));
if let Some(x) = r.results.iter_mut().find(|x| x.rule == rule) {
x.status = Status::Skipped;
x.findings.clear();
}
}
}
PanelAction::Fix(rule) => {

View File

@ -354,6 +354,9 @@ pub struct DocView {
pub(crate) text_failed: HashSet<usize>,
pub find: Option<Find>,
selection: Option<Selection>,
/// The drag in progress started a text selection: the wheel scrolls the page during it
/// (#721), which egui's scroll area refuses while anything is dragged.
selection_drag: bool,
/// Clicks in the current run on the text layer: two select a word, three the line, four the page.
clicks: u32,
last_press_time: f64,
@ -394,6 +397,7 @@ pub struct DocView {
pub(crate) edit_lines: HashMap<usize, (u64, Vec<pdfcraft_engine::TextBlock>)>,
pub line_editor: Option<crate::edit_text_ui::LineEditor>,
pub(crate) text_background: crate::edit_text_ui::TextBackground,
pub(crate) move_background: crate::content_ui::MoveBackground,
/// Edit text & images: the images per page (by document generation), and the selected one.
pub(crate) edit_images: HashMap<usize, (u64, Vec<pdfcraft_engine::PageImage>)>,
pub(crate) objects: crate::object_ui::ObjectSelection,
@ -612,6 +616,7 @@ impl DocView {
text_failed: HashSet::new(),
find: None,
selection: None,
selection_drag: false,
clicks: 0,
last_press_time: 0.0,
last_press_pos: None,
@ -635,6 +640,7 @@ impl DocView {
edit_lines: HashMap::new(),
line_editor: None,
text_background: Default::default(),
move_background: Default::default(),
edit_images: HashMap::new(),
objects: Default::default(),
image_selection: None,
@ -780,7 +786,7 @@ impl DocView {
}
pub fn render_pending(&self) -> bool {
!self.last_queue.is_empty() || self.text_background.pending()
!self.last_queue.is_empty() || self.text_background.pending() || self.move_background.pending()
}
/// Re-render every page and text layer (the document's appearance changed, e.g. a layer
@ -1343,6 +1349,7 @@ impl DocView {
// Signature previews own a separate renderer and image/background textures.
self.signature_drag = Default::default();
self.text_background = Default::default();
self.move_background = Default::default();
if !self.last_queue.is_empty() {
pool.set_queue(Vec::new());
self.last_queue.clear();
@ -1614,6 +1621,7 @@ impl DocView {
let tex = ctx.load_texture(format!("page-{:?}-{page}", self.id), img, TextureOptions::LINEAR);
self.pages.insert(page, PageTex { tag: r.request.tag, tex });
self.signature_drag.page_received(page);
self.move_background.page_received(page);
self.waiting_since.remove(&page);
if !self.stale_tiles.iter().any(|k| k.0 == page) {
self.forms.unbaked.retain(|(_, p)| *p != page);
@ -1753,6 +1761,17 @@ impl DocView {
}
}
/// The hover popup for an annotation. Replies and Fill & Sign marks (typed text, checks, signatures)
/// show none: they are page content being edited, not comments.
fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option<String> {
if a.in_reply_to.is_some() || a.fill_sign {
return None;
}
let who = a.author.clone().unwrap_or_else(|| a.subtype.clone());
let body = a.contents.clone().unwrap_or_default();
Some(if body.is_empty() { who } else { format!("{who}\n{body}") })
}
/// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536).
fn scale_tag(scale: f32) -> u64 {
(f64::from(scale) * 65536.0).round() as u64
@ -2324,6 +2343,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|| app.palette_open
|| app.password_prompt.is_some()
|| app.pending_link.is_some()
|| app.pending_remote_link.is_some()
|| app.updates.open;
let mut clicked_link: Option<LinkTarget> = None;
let mut remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> = None;
@ -2347,6 +2367,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
} else {
view.content.selected = None;
}
view.move_background.prepare(ui.ctx(), doc, &view.content, scale);
// A destination's position (bookmarks, links) refines `goto`: its point at the window's
// top-left, or the centre of a /FitR rectangle at the window's centre.
@ -2377,6 +2398,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
}
let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag());
// A drag lasts only while egui reports it, so the mark can't outlive one.
if !resp.dragged_by(egui::PointerButton::Primary) {
view.selection_drag = false;
}
// The Hand tool pans: the content widget takes every drag, so scroll by its delta.
if hand {
if resp.dragged() {
@ -2546,6 +2571,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
view.text_background.paint(painter, &xf, i, view.line_editor.as_ref());
if editing_content {
let page_tex = view.pages.get(&i).map(|p| p.tex.id());
view.move_background.paint(ui, painter, &xf, i, info, &view.content, page_tex);
}
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside);
// Comments: tools, selection, moving and resizing come before text selection.
@ -2789,6 +2818,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
if resp.drag_started() && press_here && !over_link {
view.selection_drag = true;
let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p);
let (ox, oy) = xf.screen_to_view(origin);
if view.clicks < 2 {
@ -2942,12 +2972,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// freehand, where it would cover the next stroke (#429).
let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser));
let gesturing = view.comments.gesture.is_some() || freehand;
for a in info.annotations.iter().filter(|a| a.page == i && a.in_reply_to.is_none() && !gesturing) {
for a in info.annotations.iter().filter(|a| a.page == i && !gesturing) {
let sr = xf.user_rect(info, i, a.rect);
if sr.contains(p) && hover_text.is_none() {
let who = a.author.clone().unwrap_or_else(|| a.subtype.clone());
let body = a.contents.clone().unwrap_or_default();
hover_text = Some((p, if body.is_empty() { who } else { format!("{who}\n{body}") }));
if sr.contains(p)
&& hover_text.is_none()
&& let Some(text) = annotation_hover_text(a)
{
hover_text = Some((p, text));
}
}
}
@ -2979,6 +3010,23 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
}
// ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag
// scrolls the page itself (#721). After the page loop, so it keeps scrolling once the
// pointer leaves the selection's page for the gap or the next page; over the whole
// document area, like #759. A Line/Page event clears precise_scroll_until; a Point
// gesture's eased tail after its suppression deadline is not picked up.
if view.selection_drag
&& navigation_enabled
&& unobstructed
&& !single_paging
&& view.precise_scroll_until == 0.0
&& ui.rect_contains_pointer(avail)
{
let delta = ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta));
if delta != Vec2::ZERO {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
}
}
// The page navigated to stays current while no page shows more of itself, so the next
// page step starts from it rather than from a page further down the screen (#188).
// It also stays current while it is wholly on screen: a short page (a cheque) gone to
@ -4498,6 +4546,32 @@ pub(crate) fn texture_image(size: [usize; 2], pixels: pdfcraft_render::Pixels) -
mod tests {
use super::*;
fn annotation(fill_sign: bool) -> pdfcraft_render::Annotation {
pdfcraft_render::Annotation {
page: 0,
subtype: "FreeText".into(),
author: None,
contents: Some("Hello".into()),
modified: None,
name: None,
in_reply_to: None,
rect: [0.0; 4],
color: None,
index: 0,
state: None,
quads: Vec::new(),
locked: false,
intent: None,
fill_sign,
}
}
#[test]
fn hover_popup_skips_fill_and_sign_marks() {
assert_eq!(annotation_hover_text(&annotation(false)).as_deref(), Some("FreeText\nHello"));
assert_eq!(annotation_hover_text(&annotation(true)), None);
}
#[test]
fn rendered_rasters_become_texture_images_without_a_copy() {
let pdf = b"%PDF-1.4

View File

@ -4,7 +4,7 @@ use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, ve
use crate::canvas::{DocView, Fit, PageLayout};
use crate::theme::{self, ThemePreference, Tokens};
use crate::{Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, icons, widgets};
use crate::{Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, icons, widgets, window_controls};
/// Height of the tab strip when it doubles as the macOS title bar (#789). AppKit draws the traffic
/// lights itself, centred in the standard 28 pt title-bar band at the top of the window, and moving
@ -46,14 +46,31 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
}
// GNOME on Wayland: the strip is the header bar, with the window buttons at its ends.
let fullscreen = ui.ctx().input(|i| i.viewport().fullscreen.unwrap_or(false));
let controls = app
.window_controls
.clone()
.filter(|_| app.draws_window_frame() && !fullscreen)
.unwrap_or_else(|| window_controls::Layout { left: Vec::new(), right: Vec::new() });
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if !controls.left.is_empty() {
ui.scope(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
window_controls::buttons(ui, &controls.left);
});
ui.add_space(window_controls::SIDE_GAP - 4.0);
}
if icons::button(ui, "house", button, app.active.is_none() && !app.combine_showing(), tl!("Home")).clicked() {
app.active = None;
app.combine_tab.focused = false;
}
// Keep the two icon buttons, Discord's text/padding and three gaps outside the scrolling tabs.
let controls_width = ui.fonts_mut(|f| f.layout_no_wrap("Discord".into(), theme::medium(13.0), t.text).size().x) + 2.0 * button + 50.0;
let controls_width = ui.fonts_mut(|f| f.layout_no_wrap("Discord".into(), theme::medium(13.0), t.text).size().x)
+ 2.0 * button
+ 50.0
+ window_controls::width(&controls.right);
// Open stays in sight after the tabs: its icon, text and padding, and the gaps around it.
let open_width = ui.fonts_mut(|f| f.layout_no_wrap(tl!("Open").into(), theme::medium(13.0), t.text).size().x) + 38.0 + 12.0;
let mut tabs_width = (ui.available_width() - controls_width - open_width).max(0.0);
@ -81,15 +98,19 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
if overflow && arrow(ui, "chevron-left", offset > 0.5, tl!("Scroll tabs left")) {
scroll_to = Some((offset - (TAB_MIN_WIDTH + gap)).max(0.0));
}
let state = (app.active, app.views.len(), app.combine_showing());
// Keyed by document, not index: a tab dragged past the active one moves it without
// the strip jumping to it.
let state = (app.active_ids().map(|(_, id)| id), app.views.len(), app.combine_showing());
let changed = ui.data_mut(|data| {
let id = ui.id().with("active_tab");
let changed = data.get_temp::<(Option<usize>, usize, bool)>(id) != Some(state);
let changed = data.get_temp::<(Option<pdfcraft_engine::DocId>, usize, bool)>(id) != Some(state);
data.insert_temp(id, state);
changed
});
let mut action = None;
let mut close = None;
// The pointer's x while a tab is dragged, for scrolling at the strip's edges.
let mut drag_x = None;
ui.scope(|ui| {
ui.style_mut().always_scroll_the_only_direction = true;
let mut area = egui::ScrollArea::horizontal().id_salt("document_tabs").max_width(tabs_width).auto_shrink([true, true]);
@ -98,6 +119,10 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
}
let out = area.show(ui, |ui| {
ui.horizontal_centered(|ui| {
// Each document tab's index and rect, and the one being dragged
// (with whether it was just released).
let mut slots = Vec::with_capacity(app.views.len());
let mut dragged = None;
for i in 0..app.views.len() {
let Some(doc) = app.session.get(app.views[i].id) else { continue };
let (name, dirty) = (doc.display_name(), doc.dirty);
@ -116,7 +141,12 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
i,
cap,
tab_h,
true,
);
slots.push((i, response.rect));
if response.dragged() || response.drag_stopped() {
dragged = Some((i, response.drag_stopped()));
}
if changed && app.active == Some(i) && !app.combine_showing() {
response.scroll_to_me(Some(Align::Center));
}
@ -141,6 +171,25 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
app.active = Some(i);
}
}
// Dragging a tab moves it (#1537): a line marks the gap it will land
// in, and releasing the button drops it there.
if let Some((from, released)) = dragged
&& let Some(x) = ui.ctx().pointer_latest_pos().map(|p| p.x)
{
let to = drop_index(&slots, from, x);
if released {
if close.is_none() && action.is_none() {
app.move_tab(from, to);
}
} else {
drag_x = Some(x);
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
if let Some(marker) = drop_marker(&slots, from, to, ui.spacing().item_spacing.x) {
let span = slots.first().map_or(ui.max_rect().y_range(), |(_, r)| r.y_range());
ui.painter().vline(marker, span.shrink(4.0), Stroke::new(2.0, t.accent));
}
}
}
if let Some(i) = close
&& let Some(view) = app.views.get(i)
{
@ -165,6 +214,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
usize::MAX,
cap,
tab_h,
false,
);
if changed && app.combine_showing() {
response.scroll_to_me(Some(Align::Center));
@ -180,6 +230,22 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
let max = (out.content_size.x - out.inner_rect.width()).max(0.0);
ui.data_mut(|d| d.insert_temp(scroll_id, (out.state.offset.x, max)));
// A tab dragged to either edge of a strip that scrolls brings the hidden tabs in.
if let Some(x) = drag_x
&& max > 0.0
{
let step = if x < out.inner_rect.left() + TAB_DRAG_EDGE {
-TAB_DRAG_STEP
} else if x > out.inner_rect.right() - TAB_DRAG_EDGE {
TAB_DRAG_STEP
} else {
0.0
};
if step != 0.0 {
ui.data_mut(|d| d.insert_temp(scroll_id.with("to"), (out.state.offset.x + step).clamp(0.0, max)));
ui.ctx().request_repaint();
}
}
});
if overflow && arrow(ui, "chevron-right", offset < max_offset - 0.5, tl!("Scroll tabs right")) {
scroll_to = Some((offset + TAB_MIN_WIDTH + gap).min(max_offset));
@ -194,6 +260,16 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
app.open_dialog();
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if !controls.right.is_empty() {
// Laid out left to right inside the right-to-left run, so they read in
// the desktop's order.
let size = egui::vec2(window_controls::width(&controls.right) - window_controls::SIDE_GAP, window_controls::BUTTON);
ui.allocate_ui_with_layout(size, Layout::left_to_right(Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 0.0;
window_controls::buttons(ui, &controls.right);
});
ui.add_space(window_controls::SIDE_GAP - 4.0);
}
let (icon, label) = match app.theme_preference {
ThemePreference::System => ("monitor", tl!("Use system setting")),
ThemePreference::Light => ("sun", tl!("Light gray")),
@ -262,6 +338,29 @@ fn tabs_overflow(natural: &[f32], budget: f32, gap: f32, min: f32) -> bool {
narrowest + gap * natural.len().saturating_sub(1) as f32 > budget
}
/// How close to either end of a scrolling strip a dragged tab scrolls it, and how far per frame.
const TAB_DRAG_EDGE: f32 = 24.0;
const TAB_DRAG_STEP: f32 = 8.0;
/// Where the tab at `from` lands when dropped with the pointer at `x`: past every other tab
/// whose centre is left of the pointer. `slots` are the tabs' indices and rects, in order.
fn drop_index(slots: &[(usize, Rect)], from: usize, x: f32) -> usize {
slots.iter().filter(|(i, r)| *i != from && r.center().x < x).count()
}
/// The x of the line marking where the tab at `from` drops at `to`: in the gap before the other
/// tab it lands in front of, or after the last. `None` when it would stay where it is.
fn drop_marker(slots: &[(usize, Rect)], from: usize, to: usize, gap: f32) -> Option<f32> {
if slots.iter().position(|(i, _)| *i == from) == Some(to) {
return None;
}
let mut others = slots.iter().filter(|(i, _)| *i != from).map(|(_, r)| r);
match to.checked_sub(1) {
None => others.next().map(|r| r.left() - gap / 2.0),
Some(before) => others.nth(before).map(|r| r.right() + gap / 2.0),
}
}
/// A scroll arrow beside the tabs; dimmed (and inert) at its end. True when clicked.
fn arrow(ui: &mut egui::Ui, icon: &str, enabled: bool, tip: &str) -> bool {
ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, TAB_ARROW, false, tip)).inner.clicked()
@ -316,6 +415,7 @@ fn tab(
index: usize,
cap: Option<f32>,
height: f32,
draggable: bool,
) -> egui::Response {
let font = theme::regular(13.0);
let mut label = tab_name(name);
@ -334,7 +434,8 @@ fn tab(
let height = ui.available_height().max(height);
let (rect, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
let a11y = if dirty { format!("{name} (edited)") } else { name.to_string() };
let resp = craft_ui::tabs::Tab::new(id, &a11y, active).focus_stroke(Stroke::new(1.0, t.accent)).show_at(ui, rect, |ui, resp| {
let sense = if draggable { Sense::click_and_drag() } else { Sense::click() };
let resp = craft_ui::tabs::Tab::new(id, &a11y, active).sense(sense).focus_stroke(Stroke::new(1.0, t.accent)).show_at(ui, rect, |ui, resp| {
let press = theme::Press::track(ui, resp);
let bg = if active {
press.fill(t.chrome, t.chrome.lerp_to_gamma(t.hover, 0.65), t.hover)
@ -723,6 +824,41 @@ mod tab_widths {
assert_eq!(tab_cap(&[250.0; 3], 0.0, 4.0, 110.0), Some(110.0));
}
/// Three 100-wide tabs, 4 apart, starting at x = 0.
fn three_slots() -> Vec<(usize, egui::Rect)> {
(0..3).map(|i| (i, egui::Rect::from_min_size(egui::pos2(i as f32 * 104.0, 0.0), egui::vec2(100.0, 30.0)))).collect()
}
/// A dragged tab lands past every other tab whose centre the pointer has crossed.
#[test]
fn a_dragged_tab_drops_past_the_centres_it_crossed() {
use super::drop_index;
let slots = three_slots();
// Centres at 50, 154 and 258.
assert_eq!(drop_index(&slots, 0, 10.0), 0, "still over itself");
assert_eq!(drop_index(&slots, 0, 160.0), 1, "past the middle tab's centre");
assert_eq!(drop_index(&slots, 0, 900.0), 2, "past the end");
assert_eq!(drop_index(&slots, 2, -50.0), 0, "before the start");
assert_eq!(drop_index(&slots, 2, 100.0), 1, "between the first and middle centres");
assert_eq!(drop_index(&[], 0, 100.0), 0);
}
/// The drop line sits in the gap the tab will land in, and not at all where it already is.
#[test]
fn the_drop_line_marks_the_gap_and_hides_when_nothing_moves() {
use super::drop_marker;
let slots = three_slots();
assert_eq!(drop_marker(&slots, 0, 0, 4.0), None);
assert_eq!(drop_marker(&slots, 1, 1, 4.0), None);
// The first tab dropped at the end: after the last tab.
assert_eq!(drop_marker(&slots, 0, 2, 4.0), Some(310.0));
// The first tab dropped between the middle and the last: in the gap between them.
assert_eq!(drop_marker(&slots, 0, 1, 4.0), Some(206.0));
// The last tab dropped at the start: before the first tab.
assert_eq!(drop_marker(&slots, 2, 0, 4.0), Some(-2.0));
assert_eq!(drop_marker(&[], 0, 1, 4.0), None);
}
#[test]
fn the_mac_title_bar_centres_and_clears_the_traffic_lights() {
// The lights are centred in the bar, and content keeps the same gap after them (#789).

View File

@ -380,6 +380,8 @@ pub(crate) struct Incoming {
pub modified: Option<SystemTime>,
/// Something to tell the user about where it came from.
pub note: Option<String>,
/// The open document it is, when it came from a tab.
pub open_doc: Option<pdfcraft_engine::DocId>,
}
/// A new value for [`CombineFile::auth`]: counted for the whole run, outside the undo history.
@ -400,6 +402,9 @@ pub struct CombineFile {
pub(crate) group: u64,
pub name: String,
pub bytes: Arc<Vec<u8>>,
/// The file as it was added: `bytes` itself for a PDF, the image or text it was made from
/// otherwise (converted anew, with its own date, each time it is added).
source: Arc<Vec<u8>>,
pub pages: usize,
/// Each page's displayed size in points; shared, so undo snapshots don't copy it.
pub(crate) sizes: Arc<[(f32, f32)]>,
@ -420,6 +425,8 @@ pub struct CombineFile {
pub notes: Vec<String>,
/// Where it came from, shown among the warnings (e.g. an open document's unsaved changes).
origin_note: Option<String>,
/// The open document it is, when it came from a tab: Add open documents leaves it out.
open_doc: Option<pdfcraft_engine::DocId>,
/// The range last checked and the result: the number of pages it takes and the first of
/// them (0-based), or why it is wrong.
checked: Option<(String, RangeCheck)>,
@ -436,7 +443,7 @@ impl CombineTab {
// unrelated files, and never count a source split into parts as several additions.
let mut candidates: HashMap<(&str, usize), Vec<usize>> = HashMap::new();
for (i, f) in files.iter().enumerate() {
candidates.entry((&f.name, f.bytes.len())).or_default().push(i);
candidates.entry((&f.name, f.source.len())).or_default().push(i);
}
let mut twice = vec![false; files.len()];
for indices in candidates.values() {
@ -448,7 +455,7 @@ impl CombineTab {
let mut contents: HashMap<&[u8], Vec<usize>> = HashMap::new();
for &i in indices {
if let Some(f) = files.get(i) {
contents.entry(f.bytes.as_slice()).or_default().push(i);
contents.entry(f.source.as_slice()).or_default().push(i);
}
}
for copies in contents.values() {
@ -2381,14 +2388,15 @@ impl PdfCraftApp {
/// Add picked files to the Combine files list (and show it).
pub(crate) fn stage_combine(&mut self, files: Vec<(String, Vec<u8>)>) {
let incoming = files.into_iter().map(|(name, bytes)| Incoming { name, bytes: Arc::new(bytes), modified: None, note: None }).collect();
let incoming =
files.into_iter().map(|(name, bytes)| Incoming { name, bytes: Arc::new(bytes), modified: None, note: None, open_doc: None }).collect();
self.stage_combine_with(incoming);
}
/// Add files to the Combine files list, with what can be known about them up front.
pub(crate) fn stage_combine_with(&mut self, files: Vec<Incoming>) {
let mut added = Vec::new();
for Incoming { name, bytes, modified, note } in files {
for Incoming { name, bytes, modified, note, open_doc } in files {
// Images and text files become PDFs as they join the list (#569): from then on they
// are ordinary files, with pages, thumbnails and page ranges.
// A file that can't be read joins the list all the same, with why (the Warnings
@ -2400,6 +2408,7 @@ impl PdfCraftApp {
lock: None,
notes: Vec::new(),
};
let source = bytes.clone();
let (bytes, assessed) = match self.session.as_pdf(&name, &bytes) {
Ok(pdf) => {
let assessed = assess(&pdf, None);
@ -2428,6 +2437,7 @@ impl PdfCraftApp {
group: 0,
name,
bytes,
source,
pages: 0,
range: String::new(),
modified,
@ -2438,6 +2448,7 @@ impl PdfCraftApp {
auth: 0,
notes: Vec::new(),
origin_note: note,
open_doc,
checked: None,
};
f.take(assessed, None);
@ -2536,18 +2547,22 @@ impl PdfCraftApp {
}
}
/// Add documents open in tabs (by tab index), as they are now.
/// Add documents open in tabs (by tab index), as they are now; those already in the list
/// stay out (#1356). The same document, not the same name: two files called alike are two.
pub(crate) fn add_open_documents(&mut self, tabs: &[usize]) {
let mut incoming = Vec::new();
for &i in tabs {
let Some(doc) = self.views.get(i).and_then(|v| self.session.get(v.id)) else { continue };
if self.combine_draft.iter().any(|f| f.open_doc == Some(doc.id)) {
continue;
}
#[cfg(not(target_arch = "wasm32"))]
let modified = if doc.dirty { None } else { doc.path.as_ref().and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok()) };
#[cfg(target_arch = "wasm32")]
let modified = None;
let note = doc.dirty.then(|| tl!("Includes unsaved changes").to_string());
let name = if doc.name.to_ascii_lowercase().ends_with(".pdf") { doc.name.clone() } else { format!("{}.pdf", doc.name) };
incoming.push(Incoming { name, bytes: doc.bytes.clone(), modified, note });
incoming.push(Incoming { name, bytes: doc.bytes.clone(), modified, note, open_doc: Some(doc.id) });
}
self.stage_combine_with(incoming);
}
@ -2636,6 +2651,28 @@ mod tests {
assert!(app.combine_tab.duplicate_warnings(&app.combine_draft).is_empty());
}
/// #1303: an image or text file is converted anew, with its own date, each time it is added,
/// so it is the file as added that is compared, not the PDF made from it.
#[test]
fn the_same_image_or_text_file_added_twice_is_a_duplicate() {
static NOW: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(1_700_000_000);
let mut png = std::io::Cursor::new(Vec::new());
image::RgbaImage::from_pixel(12, 4, image::Rgba([20, 30, 40, 255])).write_to(&mut png, image::ImageFormat::Png).unwrap();
let png = png.into_inner();
let warnings = |first: (&str, &[u8]), second: (&str, &[u8])| {
let mut app = PdfCraftApp::new();
app.session = std::mem::take(&mut app.session).with_clock(|| NOW.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
for (name, bytes) in [first, second] {
app.stage_combine(vec![(name.into(), bytes.to_vec())]);
}
assert_ne!(app.combine_draft[0].bytes, app.combine_draft[1].bytes, "each addition is converted with its own date");
app.combine_tab.duplicate_warnings(&app.combine_draft).to_vec()
};
assert_eq!(warnings(("notes.txt", b"Hello"), ("notes.txt", b"Hello")), [true, true], "the same text file");
assert_eq!(warnings(("photo.png", &png), ("photo.png", &png)), [true, true], "the same image");
assert_eq!(warnings(("notes.txt", b"Hello"), ("notes.txt", b"World")), [false, false], "different text, same name and size");
}
#[test]
fn every_read_of_a_file_is_new_even_after_undo() {
// Regression: the read counter lived in the undo history, so unlock → undo → unlock (with

View File

@ -260,6 +260,8 @@ impl PdfCraftApp {
}
}
"view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false),
"view.next_tab" => self.step_tab(true),
"view.previous_tab" => self.step_tab(false),
"view.theme" => {
let next = if self.theme == ThemeKind::Light { ThemePreference::Dark } else { ThemePreference::Light };
self.set_theme_preference(next);
@ -393,6 +395,11 @@ impl PdfCraftApp {
}
}
"a11y.report" => self.show_accessibility_report(),
"a11y.autotag" => {
if self.apply_edit(pdfcraft_engine::Edit::AutoTagText) {
self.choose_right_panel(Some(RightPanel::Tags));
}
}
"a11y.alt_text" => self.start_alt_text(),
"a11y.tags" => self.choose_right_panel(Some(RightPanel::Tags)),
"a11y.reading_options" => self.dialog = Some(Dialog::Properties(crate::PropsTab::Advanced)),
@ -631,6 +638,20 @@ impl PdfCraftApp {
true
}
/// Shows the next tab (or the previous one) in the strip's order, wrapping around at either
/// end (#1542). The Combine files tab, when open, comes after the documents. From Home the
/// next tab is the first and the previous one the last.
fn step_tab(&mut self, forward: bool) {
let count = self.views.len() + usize::from(self.combine_tab.open);
let current = if self.combine_showing() { Some(self.views.len()) } else { self.active };
let Some(next) = tab_after(current, count, forward) else { return };
if next < self.views.len() {
self.active = Some(next);
} else {
self.open_combine_tab();
}
}
/// Run the registered keyboard shortcuts (more specific combinations first).
pub(crate) fn registry_shortcuts(&mut self, ctx: &egui::Context) {
use egui::{Key, KeyboardShortcut, Modifiers};
@ -680,6 +701,24 @@ impl PdfCraftApp {
}
}
/// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From
/// no tab, forward is the first and back the last. `None` when there are no tabs.
fn tab_after(current: Option<usize>, count: usize, forward: bool) -> Option<usize> {
let last = count.checked_sub(1)?;
Some(match (current.filter(|&i| i <= last), forward) {
(None, true) => 0,
(None, false) => last,
(Some(i), true) => {
if i == last {
0
} else {
i + 1
}
}
(Some(i), false) => i.checked_sub(1).unwrap_or(last),
})
}
/// Render a top-level menu's registered commands (with live labels, shortcuts and enablement).
pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str) {
for spec in commands::menu(menu) {
@ -756,4 +795,20 @@ mod tests {
assert_eq!(command_modifier_label(&ctx), "⌘");
crate::i18n::set_current(previous);
}
/// Next and previous tab step one place and wrap around at either end; from no tab they go
/// to the first or the last (#1542).
#[test]
fn tab_steps_wrap_around_and_start_from_either_end() {
assert_eq!(tab_after(Some(0), 3, true), Some(1));
assert_eq!(tab_after(Some(2), 3, true), Some(0), "the last wraps to the first");
assert_eq!(tab_after(Some(1), 3, false), Some(0));
assert_eq!(tab_after(Some(0), 3, false), Some(2), "the first wraps to the last");
assert_eq!(tab_after(None, 3, true), Some(0));
assert_eq!(tab_after(None, 3, false), Some(2));
assert_eq!(tab_after(Some(0), 1, true), Some(0), "a single tab stays");
assert_eq!(tab_after(Some(7), 3, true), Some(0), "a stale index starts over");
assert_eq!(tab_after(None, 0, true), None);
assert_eq!(tab_after(Some(0), 0, false), None);
}
}

View File

@ -90,8 +90,8 @@ impl PdfCraftApp {
let Some((_, id)) = self.active_ids() else { return };
let Some(doc) = self.session.get(id) else { return };
let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && a.in_reply_to.is_none()) else { return };
let Some(tool) = crate::comments::tool_for(a) else { return };
let Some(p) = doc.comment_props(page, index) else { return };
let Some(tool) = crate::comments::tool_for_default(a, p.endings.as_deref()) else { return };
let mut style = self.comment_prefs.style(tool);
if let Some(c) = p.color {
style.color = c;

Some files were not shown because too many files have changed in this diff Show More