Merge remote-tracking branch 'upstream/main' into fix/paper-size-driver-5861

# Conflicts:
#	crates/automation/src/printing.rs
#	crates/print/src/lib.rs
#	crates/print/src/tests.rs
#	crates/ui-egui/src/app.rs
#	crates/ui-egui/src/app/files.rs
#	crates/ui-egui/src/app/prefs.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/platform/open_worker/tests.rs
#	crates/ui-egui/src/workflows/print_ui.rs
#	crates/ui-egui/src/workflows/print_ui/body.rs
#	crates/ui-egui/src/workflows/print_ui/draft.rs
#	crates/ui-egui/tests/it/print.rs
This commit is contained in:
Combiler01 committed 2026-11-10 14:18:01 +05:30
commit 4ba51acbc5
353 files changed
+43851 -2640

No files matched your search

+1
View File
@@ -175,6 +175,7 @@ PdfCraft opens files from strangers, and people trust it with their work. A malf
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state. `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim`, `crates/ui-egui/src/canvas/page_cursor.rs`) and never set it themselves. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
- **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. Modules never read the router; they are told. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it.
- **Page keys.** Page code (everything under `canvas/` and `tools/`, `app/keys.rs`, `app/clipboard.rs` and the Pages panel's `panels/pages.rs`) reads keys and clipboard events through `PageKeys` (`crates/ui-egui/src/canvas/page_keys.rs`), which has none while something is over the pages (a dialog, the palette, a popup, a menu: `PdfCraftApp::over_pages`), never egui's raw key state. The functions `NOT_PAGE_KEYS` lists, each with why, read raw: a window's or a field's own keys, which `PageKeys` would hide, and the few page reads it can't make (chords that wait for the menu bar instead, Escape ending what the pages hold, any key stopping a glide, the clipboard's hand-off to text fields). `page_pointer::tests::page_modules_read_keys_only_through_page_keys` enforces it.
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
- **Fonts** are the optional build input `CRAFT_FONTS_DIR`. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty.
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check. New workspace crates include `workspace = true` under `[lints]`. Scope `cargo clippy -p <crate> --all-targets -- -D warnings` and `cargo test -p <crate>` to the crates you touched; the workspace sweep is CI's job.
Generated
+11 -3
View File
@@ -1243,14 +1243,14 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=7ee35a9fbd47af572d55fd63dbf0333d1f00da17#7ee35a9fbd47af572d55fd63dbf0333d1f00da17"
source = "git+https://github.com/storytold/craft-libs?rev=0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d#0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d"
dependencies = [
"craft-winprint-gdi",
"log",
"miniz_oxide 0.9.1",
"roxmltree 0.21.2",
"tempfile",
"winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4)",
"winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=3c978dfcf3d460577fabd87aeea7a84ffdcfb396)",
"winsafe",
]
@@ -4416,6 +4416,7 @@ dependencies = [
"sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
"zeroize",
]
[[package]]
@@ -4656,10 +4657,12 @@ name = "pdfcraft-print"
version = "0.18.1"
dependencies = [
"craft-print",
"log",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
"pdfcraft-render",
"skrifa 0.44.0",
"thiserror 2.0.21",
"winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49)",
]
@@ -4682,8 +4685,10 @@ dependencies = [
"pdfcraft-testkit",
"pdfcraft-xfa",
"regex",
"roxmltree 0.21.2",
"sha2 0.11.1",
"thiserror 2.0.21",
"unicode-normalization",
]
[[package]]
@@ -4746,6 +4751,7 @@ dependencies = [
"sha2 0.11.1",
"sha3",
"thiserror 2.0.21",
"zeroize",
]
[[package]]
@@ -4807,6 +4813,7 @@ dependencies = [
"web-time",
"winsafe",
"xattr",
"zeroize",
]
[[package]]
@@ -8309,7 +8316,7 @@ dependencies = [
[[package]]
name = "winprint"
version = "0.2.1"
source = "git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4#51934db382f225454cf152a8657eb03789be63c4"
source = "git+https://github.com/storytold/winprint.rs?rev=3c978dfcf3d460577fabd87aeea7a84ffdcfb396#3c978dfcf3d460577fabd87aeea7a84ffdcfb396"
dependencies = [
"flate2",
"fmt-derive",
@@ -8324,6 +8331,7 @@ dependencies = [
"windows",
"windows-core",
"windows-implement",
"windows-numerics",
"xml",
]
+2 -1
View File
@@ -24,7 +24,7 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1
# the commit in `craft-i18n.rev`). Already in the tree through craft-ui.
craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "7ee35a9fbd47af572d55fd63dbf0333d1f00da17" }
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d" }
# Whether a path stored in a document may be opened (GoToR link targets).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
@@ -84,6 +84,7 @@ hayro-jbig2 = "0.3"
# foundation
thiserror = "2"
zeroize = "1"
# Linear-time matching (no backtracking) for caller-supplied redaction patterns. `perf-dfa` gives
# the lazy-DFA cache that `RegexBuilder::dfa_size_limit` bounds (the limit is inert without it).
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf-dfa"] }
+14
View File
@@ -1,5 +1,7 @@
# PdfCraft roadmap
> **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged)
>
> **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged)
> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged)
@@ -28,6 +30,7 @@
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main while retaining display-only CJK fallback and upstream Japanese font selection; estimates unchanged)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged)
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (M7, #2750: Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged.)
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** patch (M1, #1143: repaired aliases survive preflight, snapshots and normalized saves; retained materialized revisions report generation disagreements; estimates unchanged)
@@ -238,6 +241,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
- **2026-11-09 (M10, #5130):** Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Both Windows submission paths preserve DLL guards and current spooler fixes. Review follow-up validates both duplex scopes through craft-print 9fe50c94 and corrects CI formatting. Estimates unchanged.
- **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886.
- **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `<appearance>` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export keeps a stamp's /AP with its forms and images, as XFDF does, and FDF import keeps any /AP the file carries (ISO 32000-2 §12.7.8.3.4). The intent goes out as both `intent` and `IT`. An unreadable XFDF appearance is left out, said in the import report and the repair log, and the comment is drawn by PdfCraft; an FDF past the import budget is refused whole. A redaction mark is always drawn by PdfCraft.
@@ -290,6 +295,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-27 (M2, PR #4172 module-split conflicts):** Integrated main 1dd2737b, retaining viewer-only CJK fallback in split session modules and Combine thumbnail workers, and moving renderer regressions to the new test module. Validation: 798 scoped tests passed (371 engine, 412 renderer, 15 Combine; two existing engine tests ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Estimates unchanged.
- **2026-10-27 (M10, `ocr.ocr-web`, rebase fix):** The web build loads OCR models only when their SHA-256 matches `ATTRIBUTION.toml`. With ONNX loading now on, rten's parser spins forever on some bytes that aren't a model (a corrupt or wrong download), so a bad file now fails at once instead of hanging a worker.
- **2026-10-27 (M3, #3425):** A file named with open parameters, as help systems start a PDF reader (`manual.pdf#nameddest=Intro`, `#page=12` or `#Intro`), opens at that destination instead of failing to read `manual.pdf#…`: at launch, when another launch hands it over, and in its tab when it is already open. Other parameters are ignored. The app and `link_open` now also apply a GoToR link's named destination, looked up in the target's /Names /Dests or catalog /Dests, instead of warning that it wasn't applied; the app looks names up off the UI thread. `doc_open` reports the destination of a path with open parameters. Estimates unchanged.
- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence.
- **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged.
@@ -298,6 +304,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged.
- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged.
- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged.
- **2026-10-25 (M7, #2750):** Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged.
- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes.
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
@@ -330,6 +337,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
Estimates unchanged.
- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged.
- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged.
- **2026-10-22 (M10, #3132):** Windows printing no longer swaps the letters of embedded TrueType subsets. A subset renumbers its glyphs (ReportLab's Arial space is glyph 11, the installed Arial's is 3), and the Windows print path drew those numbers with the installed font of the same name: `Revisión` printed as `Wj{nxn`, the bold `z` as `Ä`, on Brother queues and the Adobe PDF printer alike. The copy sent to the Windows spooler now names every embedded TrueType/OpenType program `PdfCraftPrint<n>`, in its `name` table and in the PDF; glyphs, encodings and `/ToUnicode` are untouched and a synthetic test renders the page identically before and after. Not yet checked on a physical Windows printer. Estimates unchanged.
- **2026-10-24 (M5, border shape):** Text boxes and Fill & Sign's typed text take an oval or a circle for their border (Comment Properties ▸ Border shape, Fill & Sign's toolbar): the text box keeps its place and the shape goes around it, the text centred, a circle snug around the text (its box no wider than the text needs); stored as /RD and /PCBorderShape and drawn in the appearance. comment_add / comment_edit / fill_sign_add `border_shape`. One new label in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged.
- **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged.
- **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged.
@@ -456,6 +464,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged.
- **2026-10-15 (M14, Combine files from File Explorer, #585):** The Windows MSI adds "Combine files in PdfCraft" to PDF files' right-click menu (with the Edit verb, same switch); each selected file starts `pdfcraft --combine`, and the single-instance hand-off (`"combine": true`, `OsEvent::Combine`) collects them into the running PdfCraft's Combine files tab. Tests: the request round trip and hand-off in `single_instance.rs`, the OS event in `tests/ui.rs`, the verb rows in `test-msi.ps1`. Estimates unchanged.
- **2026-10-15 (M14, MSI install folder, #1139):** The Windows installer's welcome dialog has an "Install to" row with a native folder browser, starting on the folder chosen for the per-user or per-machine scope (the recorded one on an update). `test-msi.ps1` checks the dialog wiring: both Install buttons set the folder before the dialog closes, and Browse's OK and Cancel. Estimates unchanged.
- **2026-10-15 (M8, #2448):** Applying redactions also removes the document information entries and the document's and pages' XMP metadata that repeat the removed text, so a redacted word can't survive in `/Title`, `/Keywords` or the XMP that mirrors them. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
- **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged.
- **2026-10-15 (M1, PR #1143 refresh):** COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration also keeps edits to objects with nonzero generations before incremental saves. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. Current upstream async-opening test corrections are included. Estimates unchanged.
@@ -526,7 +535,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732.
- **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off.
- **2026-10-14 (M10, #2001):** The Print dialog remembers two-sided, grayscale, collate, page sizing (with its custom percent) and Comments & forms across restarts, under the names `doc_print` uses; copies, pages, layout and orientation still start fresh for each job. The option names now live in one place (`pdfcraft_print::names`) for the tool and the settings file.
- **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it.
- **2026-10-14 (M5, comment pop-ups; reworked 2026-11-01):** A double-click on a comment opens its pop-up window on the page: a title bar with the comment's kind, author and date; its text and its replies' typed in place, the replies as panels each inside the one it answers, a Reply button under each; the window keeps its `/Rect` size on screen as the page zooms, scrolls its thread, drags by its title bar, resizes by its corner and closes with ×, each an undo step saved in the file's `/Popup` so other viewers agree. A faint wedge in the comment's colour joins the comment to its open window on roll-over or selection. `comment_popup` does the same headlessly and `comment_list` reports the pop-up.
- **2026-10-15 (M6, #1741):** Custom stamps can be dynamic: Create Custom Stamp adds an optional line under the picture, a template with {name}, {date} and {time} filled in when the stamp is placed (the built-in dynamic stamps' format), kept in the stamp library; `stamp_custom` takes the same `line`.
- **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-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
@@ -824,6 +836,7 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | patch | M10 #5130: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Estimates unchanged. |
| 2026-11-08 | patch | M10 #6514/#636: retain main's public GDI Git revision (64e7afd4) to preserve downstream Rust type identity, update the internal PDF loader independently, and refresh the application dependency/lockfile. New-graph CI pending; estimates unchanged. |
| 2026-11-08 | patch | M10 #6514: refresh the craft-print and winprint/GDI pins after #605 merged; retain the Direct2D crash fix and stream-test cleanup repairs, record single-page HUAWEI output with its diagnostic limits, and rerun CI for the new dependency graph. Estimates unchanged. |
| 2026-11-07 | patch | M10 #6514: load Windows PDF print jobs on demand through a read-only file stream, avoiding StorageFile access denial, whole-file buffering and per-chunk async waits; approved dependencies retain the #5006 crash fix. Regression tests pass; physical output remains unqualified; estimates unchanged. |
@@ -898,6 +911,7 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`).
- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.
- **2026-10-22 (M3, #3530, builds on PR #3547):** A find step keeps its match in the middle of the window after zooming: a zoom pending in the same frame no longer puts the page-top jump off to the next frame, and the rest of an eased ⌘/Ctrl-wheel zoom anchors on the match instead of the pointer. Synthetic UI regressions step through matches on pages larger and smaller than the window at 10–3200% and in every page display. Estimates unchanged.
- **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.
+13
View File
@@ -6,12 +6,25 @@
//! cross-compile from macOS or Linux still links, unless `PDFCRAFT_REQUIRE_WINRES=1` turns it
//! into an error (for release builds).
/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the
/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a
/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they
/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in.
/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`.
const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"];
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=PDFCRAFT_REQUIRE_WINRES");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
for dll in DELAY_LOADED {
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}");
}
println!("cargo:rustc-link-arg-bins=delayimp.lib");
}
let mut res = winresource::WindowsResource::new();
res.set("ProductName", "PdfCraft")
.set("FileDescription", "PdfCraft command-line tool")
+4 -1
View File
@@ -434,7 +434,10 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
for warning in &p.warnings {
match warning {
pdfcraft_render::RenderWarning::ContentTruncated => {
let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped");
let _ = writeln!(
std::io::stderr().lock(),
"warning: page {page}: content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped"
);
}
pdfcraft_render::RenderWarning::ColourFallback => {
let _ =
+13
View File
@@ -9,6 +9,13 @@
#[path = "src/windows_manifest.rs"]
mod windows_manifest;
/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the
/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a
/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they
/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in.
/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`.
const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"];
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../assets/app-icon/pdfcraft.ico");
@@ -18,6 +25,12 @@ fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
for dll in DELAY_LOADED {
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}");
}
println!("cargo:rustc-link-arg-bins=delayimp.lib");
}
let mut res = winresource::WindowsResource::new();
res.set_icon("../../assets/app-icon/pdfcraft.ico")
.set_icon_with_id("../../assets/app-icon/pdfcraft-document.ico", "2")
+1
View File
@@ -475,6 +475,7 @@ fn app_creator<'a>(
}
app.set_startup_update_check(true);
app.printer_source = Some(pdfcraft_ui_egui::system_printers());
app.two_sided_source = Some(pdfcraft_ui_egui::system_two_sided());
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
#[cfg(target_os = "macos")]
{
+33 -2
View File
@@ -152,9 +152,20 @@ pub fn parse_request(line: &str) -> Option<(Vec<String>, bool)> {
}
/// File arguments made absolute against this launch's working folder, which the running app
/// doesn't share. A path that can't be made absolute is sent as it is.
/// doesn't share. A path that can't be made absolute is sent as it is. Of a file named with open
/// parameters (`manual.pdf#nameddest=Intro`, #3425), only the file is made absolute and the
/// parameters are sent as given: as part of the path, `//` and `/./` in a destination name were
/// folded, and on Windows `/` became `\`, `..` took the file name with it and a trailing dot went.
pub fn absolute(paths: &[String]) -> Vec<String> {
paths.iter().map(|p| std::path::absolute(p).map_or_else(|_| p.clone(), |a| a.to_string_lossy().into_owned())).collect()
let made = |p: &str| std::path::absolute(p).map_or_else(|_| p.to_string(), |a| a.to_string_lossy().into_owned());
paths
.iter()
.map(|p| match pdfcraft_engine::open_parameters::split(p, |f| std::path::Path::new(f).is_file()) {
// `file` is what comes before one of `p`'s `#`, so the rest starts at that `#`.
Some((file, _)) => format!("{}{}", made(&file), p.get(file.len()..).unwrap_or_default()),
None => made(p),
})
.collect()
}
/// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false
@@ -416,6 +427,26 @@ mod tests {
assert_eq!(absolute(std::slice::from_ref(&already)), vec![already]);
}
/// #3425: the open parameters after a file reach the running app as they were given. Made
/// absolute with the file, `a//b/./c` became `a/b/c` (`a\b\c` on Windows), and on Windows
/// `x/../y` lost the file name and `Intro.` its dot.
#[test]
fn only_the_file_before_open_parameters_is_made_absolute() {
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-open-parameters", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (manual, notes) = (dir.join("manual.pdf"), dir.join("notes#2.pdf"));
std::fs::write(&manual, b"%PDF-1.7").unwrap();
std::fs::write(&notes, b"%PDF-1.7").unwrap();
let made = |p: &std::path::Path| std::path::absolute(p).unwrap().to_string_lossy().into_owned();
for parameters in ["#nameddest=a//b/./c", "#nameddest=x/../y", "#Intro.", "#page=2"] {
let given = format!("{}{parameters}", manual.to_string_lossy());
assert_eq!(absolute(&[given]), vec![format!("{}{parameters}", made(&manual))], "{parameters}");
}
// A file with `#` in its name is made absolute whole, as before.
assert_eq!(absolute(&[notes.to_string_lossy().into_owned()]), vec![made(&notes)]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn each_windows_session_gets_its_own_socket_name() {
// The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the
+8
View File
@@ -57,7 +57,15 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
let mut widgets = Vec::new();
// A widget listed in more than one page's /Annots is tagged once (one key, one /Form).
let mut seen = std::collections::HashSet::new();
// A shared indirect /Annots array is scanned for the first page that lists it: its widgets
// are tagged once anyway, so scanning it again for every page only repeats the work.
let mut arrays = std::collections::HashSet::new();
for page in &pages {
if let Some(array) = page.dict.get(b"Annots").and_then(Object::as_ref)
&& !arrays.insert(array)
{
continue;
}
let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).as_deref().and_then(Object::as_array).cloned().unwrap_or_default();
for annot in annots {
let Some(reference) = annot.as_ref() else { continue };
+12 -8
View File
@@ -262,11 +262,13 @@ fn annotation_rect(doc: &Document, annot: &Dict) -> Option<[f64; 4]> {
pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tagged: bool) -> Vec<Finding> {
let mut out = Vec::new();
let annots = |p: &Page| -> Vec<(ObjRef, Dict)> {
// The annotation objects are shared, never deep-copied: a reference repeated across a page's
// Annots array (or its pages) must not copy the dictionary each time.
let annots = |p: &Page| -> Vec<(ObjRef, std::sync::Arc<Object>)> {
let a = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
a.iter()
.filter_map(|x| x.as_ref().and_then(|r| doc.get(r).as_dict().cloned().map(|d| (r, d))))
.filter(|(_, d)| d.name(b"Subtype") != Some(b"Popup"))
.filter_map(|x| x.as_ref().map(|r| (r, doc.get(r))))
.filter(|(_, o)| o.as_dict().is_some_and(|d| d.name(b"Subtype") != Some(b"Popup")))
.collect()
};
match rule {
@@ -284,7 +286,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
Rule::TaggedAnnotations | Rule::TaggedMultimedia | Rule::TaggedFormFields => {
for &p in list {
let Some(page) = pages.get(p) else { continue };
for (_, a) in annots(page) {
for (_, obj) in annots(page) {
let Some(a) = obj.as_dict() else { continue };
let st = a.name(b"Subtype").unwrap_or(b"");
let applies = match rule {
Rule::TaggedFormFields => st == b"Widget",
@@ -299,11 +302,11 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
let pattern = if st == b"Widget" {
"Form field on page {p} isn't tagged"
} else {
values.push(("{}", subtype_name(&a)));
values.push(("{}", subtype_name(a)));
"{} annotation on page {p} isn't tagged"
};
let mut finding = Finding::worded(Some(p), pattern, values);
finding.bbox = annotation_rect(doc, &a);
finding.bbox = annotation_rect(doc, a);
out.push(finding);
}
}
@@ -345,7 +348,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
let mut seen = HashSet::new();
for &p in list {
let Some(page) = pages.get(p) else { continue };
for (r, a) in annots(page) {
for (r, obj) in annots(page) {
let Some(a) = obj.as_dict() else { continue };
if a.name(b"Subtype") != Some(b"Widget") {
continue;
}
@@ -362,7 +366,7 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
"Field \"{}\" on page {p} has no description (tooltip)",
vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())],
);
finding.bbox = annotation_rect(doc, &a);
finding.bbox = annotation_rect(doc, a);
out.push(finding);
}
}
+34
View File
@@ -1883,3 +1883,37 @@ fn autotag_text_refuses_pages_that_share_content_past_the_total_operator_allowan
let Err(AutoTagError::Unsupported { reason, .. }) = autotag_text(&mut doc) else { panic!("expected a refusal") };
assert!(reason.contains("too much content"), "{reason}");
}
#[test]
fn autotag_form_fields_tags_widgets_of_a_shared_annots_array_once() {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 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] /Annots 7 0 R >>".into(),
"<< /Length 0 >>\nstream\n\nendstream".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >>".into(),
"[5 0 R 5 0 R]".into(),
],
"",
);
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
}
#[test]
fn checker_reports_each_untagged_annotation_without_copying_shared_dictionaries() {
let 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] /Annots [4 0 R 4 0 R 5 0 R] >>".into(),
"<< /Type /Annot /Subtype /Text /Rect [20 60 120 80] >>".into(),
"<< /Type /Annot /Subtype /Popup /Rect [20 60 120 80] >>".into(),
],
"",
);
let pages = pdfcraft_model::pages(&doc);
let found = content::page_rule(&doc, Rule::TaggedAnnotations, &pages, &[0], &Default::default(), false);
assert_eq!(found.len(), 2, "the popup is skipped, each listed text annotation reported");
}
+3 -1
View File
@@ -27,7 +27,9 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
comment another program drew with a font that has those characters, whose appearance is kept
rather than redrawn with `?` (new text for it is taken).
- **Edits:** reply (`/IRT`), set review status (a `/State` reply, as Acrobat's "Set status"
does), change text, move, resize, restyle, delete (with pop-up and replies).
does), change text, move, resize, restyle, delete (with pop-up and replies), and open, close
or move the pop-up window (`set_popup`: `/Popup` `/Open` and `/Rect`, created beside a comment
that has none; a note's own `/Open` follows). `summaries` report each comment's pop-up.
## API sketch
+115 -31
View File
@@ -8,19 +8,25 @@
//! itself; for the other values, callers that hold the document follow references first
//! (`draw::with_direct_values`).
//!
//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use the standard
//! Helvetica font with WinAnsi encoding, and draw Thai from the craft-fonts Thai face as Type 3
//! glyphs (`pdfcraft_fonts::shaped_text`, the writer Add text uses); line breaking uses
//! [`text_width`], the standard-14 metrics and the Thai face's shaped advances.
//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use a standard text
//! font with WinAnsi encoding: Helvetica, or the face their `/DA` or `/DS` asks for
//! ([`crate::text_style`]), and draw Thai from the craft-fonts Thai face as Type 3 glyphs
//! (`pdfcraft_fonts::shaped_text`, the writer Add text uses); lines break at that face's
//! standard-14 widths and the Thai face's shaped advances (no other font program is bundled).
use pdfcraft_cos::{Dict, Object, PdfString, Stream};
use pdfcraft_fonts::shaped_text;
use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, win_ansi, wrap_with};
use pdfcraft_fonts::{first_undrawable, literal, win_ansi, wrap_with};
/// The width of `text` at `size` as appearances draw it: Helvetica, with Thai shaped in the Thai
/// face that text boxes draw it with.
pub fn text_width(text: &str, size: f64) -> f64 {
shaped_text::thai_width(text, size, helvetica_width)
text_width_in(pdfcraft_fonts::Std14::Helvetica, text, size)
}
/// [`text_width`] in the standard `face` a text box draws its other text in.
pub fn text_width_in(face: pdfcraft_fonts::Std14, text: &str, size: f64) -> f64 {
shaped_text::thai_width(text, size, |s, size| face.text_width(s, size))
}
/// `text` wrapped `width` wide as [`text_width`] measures it; a Thai mark stays on its letter.
@@ -410,13 +416,17 @@ pub fn da_border(d: &Dict) -> Option<Rgb> {
col
}
/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt.
/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt: a `Tf` without a
/// usable size (missing, zero, negative or not a number) keeps 12 pt, and sizes past 400 pt are
/// capped. A text box has no automatic size, so a zero size, which means auto-size in a form
/// field's `/DA` (ISO 32000-2 §12.7.4.3), is the default here. An operand that isn't a finite
/// number (`NaN`, `inf`, which Rust's parser would otherwise accept) is ignored with its operator.
pub fn parse_da(d: &Dict) -> (Rgb, f64) {
let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap_or_default();
let toks: Vec<&str> = da.split_whitespace().collect();
let mut col = [0.0; 3];
let mut size = 12.0;
let num = |i: usize| toks.get(i).and_then(|t| t.parse::<f64>().ok());
let num = |i: usize| toks.get(i).and_then(|t| t.parse::<f64>().ok()).filter(|v| v.is_finite());
for (i, t) in toks.iter().enumerate() {
match *t {
"rg" if i >= 3 => {
@@ -544,7 +554,9 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
b"FreeText" => return Some(("the comment's text", shaped_text::first_undrawable(&text(b"Contents")?)?)),
b"Line" if !d.contains(b"PCMeasureValue") => ("the line's caption", line_caption(d)?),
b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?),
b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?),
b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") => {
("the stamp's \"By\" line", text(b"PCByLine")?)
}
// Drawn in Helvetica when the redaction is applied: refused when it is marked.
b"Redact" => ("the redaction's overlay text", text(b"OverlayText")?),
_ => return None,
@@ -554,18 +566,19 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding.
pub(crate) fn helvetica_resource() -> Object {
Object::Dict(helvetica_fonts())
Object::Dict(font_resources(pdfcraft_fonts::Std14::Helvetica))
}
/// [`helvetica_resource`]'s dictionary.
fn helvetica_fonts() -> Dict {
/// `/Font` resources naming one standard face with WinAnsiEncoding, under the resource name
/// [`crate::text_style::resource_name`] gives it (`/Helv` for Helvetica).
fn font_resources(face: pdfcraft_fonts::Std14) -> Dict {
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"BaseFont".to_vec(), Object::name(face.base_font()));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(font));
fonts.set(crate::text_style::resource_name(face).as_bytes().to_vec(), Object::Dict(font));
fonts
}
@@ -661,7 +674,8 @@ pub(crate) fn build_on_page(doc: &Document, d: &Dict, page: Option<[f64; 4]>) ->
b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing),
b"FileAttachment" => return draw_icon(&context, "PushPin", [0.0, 0.47, 0.84], drawing),
b"Stamp" => return draw_stamp(&context, drawing),
b"FreeText" => return draw_free_text(&context, drawing),
// The face a text box's `/DA` font names in the form's `/DR`, when it names one.
b"FreeText" => return draw_free_text(&context, drawing, crate::text_style::dr_face(doc, d)),
_ => {}
}
let mut drawing = drawing;
@@ -971,11 +985,31 @@ fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing)
}
fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
let d = context.dict;
let stroke = context.stroke;
let c = &mut drawing.content;
// While marked: the outline of each area (the fill comes when applied).
let q = quads(context)?;
let col = stroke.unwrap_or([0.89, 0.13, 0.13]);
// ISO 32000-2 gives a mark no outline colour (§12.5.6.23, Table 195). An array `/OC` of
// three numbers is the one some writers give it, and is drawn (the observation is in
// `parity/acrobat-features.toml`); `/OC` as ISO 32000-2 defines it (Table 166) is an
// optional-content dictionary, which this leaves alone.
let outline = nums(d, b"OC").and_then(|c| match c.as_slice() {
[r, g, b] if c.iter().all(|x| x.is_finite()) => Some([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]),
_ => None,
});
// Quadrilaterals are outlined in the `/OC` colour, else in `/C` as they always were.
let (q, col) = match d.get(b"QuadPoints") {
Some(_) => (quads(context)?, outline.or(stroke)),
// Without `/QuadPoints`, `/Rect` is the area (Table 195): outlined just inside its
// edges when the mark names an outline colour, else not drawn while marked.
None => {
let outline = outline?;
let rect = context.rect;
let [x0, y0, x1, y1] = [rect[0] + 0.5, rect[1] + 0.5, rect[2] - 0.5, rect[3] - 0.5];
(vec![[x0, y1, x1, y1, x0, y0, x1, y0]], Some(outline))
}
};
let col = col.unwrap_or([0.89, 0.13, 0.13]);
c.push_str(&format!("{}1 w\n", rg_stroke(col)));
for quad in q.iter() {
c.push_str(&format!(
@@ -1230,7 +1264,8 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
return Some(form(rect, c.as_bytes(), res));
}
// A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills
// the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets
// the rectangle. An image signature or a stamp with a line added to a turned page
// (`/PCPictureRotate`) gets
// the standard counter-rotation as its form `/Matrix`: viewers fit the turned
// bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed.
if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) {
@@ -1239,14 +1274,61 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
[w, h] if *w > 0.0 && *h > 0.0 => Some((*w, *h)),
_ => None,
});
let place = picture_place(rect, image, size)?;
// A text line (#1741) takes the bottom of the rectangle, and the picture keeps its
// shape, centred above it. Without one the picture fills the rectangle.
let line = d.get(b"PCByLine").and_then(Object::as_string).map(PdfString::to_text).filter(|l| !l.trim().is_empty());
let turn = picture_turn(d);
// A lined stamp on a quarter-turned page is laid out in its displayed box (the
// rectangle with its sides swapped) and turned into `/Rect` by the `/Matrix` below.
// Laid out in user space, the viewer's fit of the turned box to `/Rect` would scale
// the two displayed axes differently and squash the picture and its line.
let bbox = if line.is_some() && matches!(turn, 90 | 270) { [0.0, 0.0, rect[3] - rect[1], rect[2] - rect[0]] } else { rect };
let [x0, y0, x1, y1] = bbox;
let band = if line.is_some() { (y1 - y0) * crate::CUSTOM_LINE_BAND } else { 0.0 };
let (w, h) = (x1 - x0, y1 - y0 - band);
let place = match (&line, size) {
(Some(_), Some((sw, sh))) => {
let k = (w / sw).min(h / sh).max(0.0);
let (pw, ph) = (sw * k, sh * k);
let (px, py) = (x0 + (w - pw) / 2.0, y0 + band + (h - ph) / 2.0);
if image {
format!("{} 0 0 {} {} {} cm", n(pw), n(ph), n(px), n(py))
} else {
format!("{} 0 0 {} {} {} cm", n(k), n(k), n(px), n(py))
}
}
// Without a line the band is empty and this is the rectangle itself.
_ => picture_place([x0, y0 + band, x1, y1], image, size)?,
};
c.push_str(&format!("q {place} /Pic Do Q\n"));
// Bytes from here: the line is WinAnsi, not UTF-8.
let mut content = std::mem::take(c).into_bytes();
if let Some(l) = &line {
// Centred in the band, at its usual size unless the stamp was made narrower.
let mut size = band * crate::CUSTOM_LINE_TEXT;
let tw = text_width(l, size);
if tw > w - 4.0 && tw > 0.0 {
size *= ((w - 4.0) / tw).max(0.0);
}
let tw = text_width(l, size);
let ty = y0 + (band - size * 0.72) / 2.0;
content.extend(format!("BT\n0.2 0.2 0.2 rg\n/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(size), n(x0 + (w - tw) / 2.0), n(ty)).bytes());
content.extend(literal(&win_ansi(l)));
content.extend_from_slice(b" Tj\nET\n");
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(f));
res.set(b"Font".to_vec(), Object::Dict(fonts));
}
let mut xo = Dict::new();
xo.set(b"Pic".to_vec(), Object::Ref(pic));
res.set(b"XObject".to_vec(), Object::Dict(xo));
let mut stream = form(rect, c.as_bytes(), res);
let turn = picture_turn(d);
if image && turn != 0 {
let mut stream = form(bbox, &content, res);
if (image || line.is_some()) && turn != 0 {
// The page's view matrix without its offset: display axes in user space.
let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]);
let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0];
@@ -1359,7 +1441,7 @@ fn shaped_border(
Some(rect)
}
fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option<Stream> {
fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing, dr_face: Option<pdfcraft_fonts::Std14>) -> Option<Stream> {
let d = context.dict;
let parts = context.parts;
let rect = context.rect;
@@ -1367,11 +1449,12 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
let mut c = std::mem::take(&mut drawing.content);
let mut res = std::mem::take(&mut drawing.resources);
let _tail = std::mem::take(&mut drawing.tail);
let (text_color, size) = parse_da(d);
let crate::text_style::TextStyle { face, size, color: text_color } = crate::text_style::text_style(d, dr_face);
// The box's border and a callout's leader line: their own colour, else the text's.
let line_color = da_border(d).unwrap_or(text_color);
let bg = stroke;
let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 };
// Without `/BS` or `/Border`, the `/Border` default [0 0 1]: a 1-pt border (Table 166).
let bw = border_width(d);
// A callout: the leader line (arrowhead at its first point), and the text box inside
// `/Rect` by `/RD`.
// Text added to a turned page is drawn upright in its displayed box, which the form
@@ -1469,37 +1552,38 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
c.push_str("/GS1 gs\n");
}
c.push_str(&format!(
"{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}",
"{} {} {} {} re W n\nBT\n/{} {} Tf\n{}",
n(rect[0]),
n(rect[1]),
n(rect[2] - rect[0]),
n(rect[3] - rect[1]),
crate::text_style::resource_name(face),
n(size),
rg(text_color)
));
let mut out = c.into_bytes();
let lines = wrap(&text, size, width);
let lines = pdfcraft_fonts::wrap_with(&text, size, width, |s, size| text_width_in(face, s, size));
let mut y = rect[3] - pad - size * reach(&lines)[0];
if round {
y -= ((rect[3] - rect[1] - 2.0 * pad - text_lines_height(&lines, size)) / 2.0).max(0.0);
}
// Thai is shaped and drawn from the Thai face (Type 3 fonts in the form's resources, whose
// streams `apply_appearance` stores in the document); the rest, and Thai it can't draw (as
// `?`, which `undrawable_text` reports), in Helvetica.
let mut fonts = helvetica_fonts();
// `?`, which `undrawable_text` reports), in the box's standard face.
let mut fonts = font_resources(face);
let (pieces, shaped) = shaped_text::appearance_lines(&lines, &mut fonts);
let mut current = None;
for pieces in &pieces {
if y < rect[1] - size {
break;
}
let lw = shaped_text::pieces_width(pieces, size, 0.0, helvetica_width);
let lw = shaped_text::pieces_width(pieces, size, 0.0, |s, size| face.text_width(s, size));
let x = match q {
1 => rect[0] + pad + (width - lw) / 2.0,
2 => rect[2] - pad - lw,
_ => rect[0] + pad,
};
shaped.show_line(&mut out, pieces, x, y, size, "Helv", win_ansi, |s| helvetica_width(s, size), &mut current);
shaped.show_line(&mut out, pieces, x, y, size, crate::text_style::resource_name(face), win_ansi, |s| face.text_width(s, size), &mut current);
y -= size * 1.2;
}
out.extend_from_slice(b"ET\n");
+127 -10
View File
@@ -204,14 +204,110 @@ fn subject(shape: &Shape) -> &'static str {
pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] {
let [x0, y0, x1, y1] = normalize(rect);
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
if knee[0] < x0 {
[x0, cy]
} else if knee[0] > x1 {
[x1, cy]
} else if knee[1] > y1 {
[cx, y1]
match callout_side(rect, knee) {
Side::Left => [x0, cy],
Side::Right => [x1, cy],
Side::Top => [cx, y1],
Side::Bottom => [cx, y0],
}
}
/// A side of a callout's text box.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
Left,
Right,
Top,
Bottom,
}
impl Side {
/// Whether a knee line on this side is horizontal (it meets the left or right side). Acrobat
/// lets a knee line be resized along its own direction only (Acrobat DC Help, 11/28/2019,
/// p. 357).
pub fn horizontal(self) -> bool {
matches!(self, Side::Left | Side::Right)
}
}
/// The side of the text box `rect` that faces `knee`: the one [`callout_attach`] attaches to.
///
/// Every position has one side (review on #4094):
/// - left or right of the box (`x < x0`, `x > x1`): that side, whatever `y` is, so a knee off a
/// corner diagonally, or on the line of the top or bottom edge past the box, is beside it;
/// - otherwise above or below it (`y > y1`, `y < y0`): that side, so a knee on the line of the
/// left or right edge past the box is above or below it;
/// - on the box's outline, or within `CALLOUT_SLACK` (0.01 pt) inside it, since the page hands
/// positions back as `f32` values: the side whose edge it lies on, the left or right edge
/// first, so a corner takes the left or right side as a knee beside the box does. The line
/// stays out of the box (it was sent across the box to the bottom side);
/// - inside the box, or with a coordinate that isn't a number: the bottom side.
pub fn callout_side(rect: [f64; 4], knee: [f64; 2]) -> Side {
let [x0, y0, x1, y1] = normalize(rect);
let [x, y] = knee;
// Before any comparison: one with NaN is false, so the other coordinate alone would decide.
if x.is_nan() || y.is_nan() {
return Side::Bottom;
}
if x < x0 {
Side::Left
} else if x > x1 {
Side::Right
} else if y > y1 {
Side::Top
} else if y < y0 {
Side::Bottom
} else if x - x0 < CALLOUT_SLACK {
Side::Left
} else if x1 - x < CALLOUT_SLACK {
Side::Right
} else if y1 - y < CALLOUT_SLACK {
Side::Top
} else {
[cx, y0]
Side::Bottom
}
}
/// A callout's knee line when Acrobat lays it out: 12 pt, out from the middle of the side. Seen in
/// Acrobat Pro DC, a 2021 Continuous release: a new callout's knee line, and one laid out again
/// after its knee was dragged into the box or the side it meets was moved, are all 12 pt, and the
/// knee can't come nearer the box than that.
pub const CALLOUT_KNEE_LINE: f64 = 12.0;
/// Where a knee goes on `side` of the text box `rect`: [`CALLOUT_KNEE_LINE`] out from the middle
/// of that side, so the knee line is level (left, right) or upright (top, bottom).
pub fn callout_knee_on(rect: [f64; 4], side: Side) -> [f64; 2] {
let [x0, y0, x1, y1] = normalize(rect);
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
match side {
Side::Left => [x0 - CALLOUT_KNEE_LINE, cy],
Side::Right => [x1 + CALLOUT_KNEE_LINE, cy],
Side::Top => [cx, y1 + CALLOUT_KNEE_LINE],
Side::Bottom => [cx, y0 - CALLOUT_KNEE_LINE],
}
}
/// A new callout's knee: on the side of its text box `rect` that faces the arrow `point`
/// ([`callout_knee_on`]).
pub fn callout_knee(rect: [f64; 4], point: [f64; 2]) -> [f64; 2] {
callout_knee_on(rect, callout_side(rect, point))
}
/// How far apart, in points, two positions of a callout's box or line may be and still count as the
/// same. The page hands a box back as `f32` values, which moves a coordinate such as 323.05 by a few
/// hundred-thousandths of a point (review on #4094).
pub(crate) const CALLOUT_SLACK: f64 = 0.01;
/// Whether `end` lies on `side` of the text box `rect`: the leader line can stay where it meets it.
pub(crate) fn on_side(rect: [f64; 4], side: Side, end: [f64; 2]) -> bool {
let [x0, y0, x1, y1] = normalize(rect);
let near = |a: f64, b: f64| (a - b).abs() < CALLOUT_SLACK;
let within = |v: f64, lo: f64, hi: f64| v > lo - CALLOUT_SLACK && v < hi + CALLOUT_SLACK;
match side {
Side::Left => near(end[0], x0) && within(end[1], y0, y1),
Side::Right => near(end[0], x1) && within(end[1], y0, y1),
Side::Top => near(end[1], y1) && within(end[0], x0, x1),
Side::Bottom => near(end[1], y0) && within(end[0], x0, x1),
}
}
@@ -296,15 +392,20 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::<Vec<_>>())).collect();
d.set(b"PCOutline".to_vec(), Object::Array(list));
}
Shape::CustomStamp { name, picture, image, size, .. } => {
Shape::CustomStamp { name, picture, image, size, line, .. } => {
let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect();
d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}")));
d.set(b"PCPicture".to_vec(), Object::Ref(*picture));
d.set(b"PCPictureImage".to_vec(), Object::Bool(*image));
d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1]));
let lined = line.as_deref().map(str::trim).filter(|l| !l.is_empty());
if let Some(l) = lined {
d.set(b"PCByLine".to_vec(), PdfString::text(l));
}
// An image signature's rectangle is in user space; on a turned page its appearance is
// turned back (see `appearance::build`), so it reads upright as displayed.
if *image && matches!(clean.as_str(), "Signature" | "Initials") {
// turned back (see `appearance::build`), so it reads upright as displayed. A stamp
// with a line is laid out in the displayed box, so it needs the turn too.
if lined.is_some() || (*image && matches!(clean.as_str(), "Signature" | "Initials")) {
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
if turn != 0 {
d.set(b"PCPictureRotate".to_vec(), Object::Int(turn));
@@ -500,6 +601,22 @@ fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots:
Ok(())
}
/// The share of a custom stamp's height its text line takes (the rest is the picture).
pub const CUSTOM_LINE_BAND: f64 = 0.22;
/// A custom stamp line's text height as a share of its band.
pub const CUSTOM_LINE_TEXT: f64 = 0.62;
/// The natural size of a custom stamp whose picture is `picture` points (#1741): with `line`,
/// the band under the picture is added, and the stamp is as wide as the line at its usual size
/// when that is wider than the picture.
pub fn custom_stamp_size(picture: (f64, f64), line: Option<&str>) -> (f64, f64) {
let Some(line) = line else { return picture };
let h = picture.1 / (1.0 - CUSTOM_LINE_BAND);
let text = appearance::text_width(line, h * CUSTOM_LINE_BAND * CUSTOM_LINE_TEXT) + 8.0;
(picture.0.max(text), h)
}
/// 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.
+102 -10
View File
@@ -1,7 +1,8 @@
use super::create::finite;
use super::draw::set_appearance;
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs};
use super::modify::{fillable, touch, unlocked};
use super::modify::{callout_line, callout_text_box, fillable, touch, unlocked};
use super::text_style;
use super::types::{BorderShape, FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
@@ -23,7 +24,8 @@ pub struct Summary {
pub in_reply_to: Option<String>,
/// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique).
pub parent: Option<(usize, usize)>,
/// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it.
/// `/RT /Group`: with an `/IRT` reference, a subordinate of the group whose primary that is
/// ([`is_group_subordinate`]), not a reply to it.
pub grouped: bool,
pub rect: [f32; 4],
pub color: Option<[f32; 3]>,
@@ -35,9 +37,66 @@ pub struct Summary {
pub fill_sign: bool,
/// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`.
pub no_rotate: bool,
/// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud
/// (`/Vertices`), in user space; empty for other comments.
/// A line's two ends (`/L`), the corners of connected lines, a polygon or a cloud
/// (`/Vertices`), or a callout's arrow point and knee (`/CL` without its end, which follows
/// the text box), in user space; empty for other comments.
pub points: Vec<[f32; 2]>,
/// A callout's text box (`/Rect` inset by `/RD`), in user space; `None` for other comments.
pub text_box: Option<[f32; 4]>,
/// The comment's pop-up window (`/Popup`), when it has one.
pub popup: Option<PopupInfo>,
}
impl Summary {
/// A subordinate of a group (`/RT /Group` with an `/IRT` to a listed comment, ISO 32000-2
/// §12.5.6.2): part of its primary's comment, not a reply. As
/// `pdfcraft_render::Annotation::is_group_member`, which also drops a looping `/IRT`.
pub fn is_group_member(&self) -> bool {
self.grouped && self.parent.is_some()
}
}
/// A comment's pop-up window (ISO 32000-2 §12.5.6.14): where it sits on the page and whether it
/// is shown open.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PopupInfo {
/// `/Rect` of the `/Popup` annotation, in user space.
pub rect: [f32; 4],
/// `/Open` of the `/Popup` annotation.
pub open: bool,
}
/// The pop-up the comment `annot` (the object `own`, when it is one of its own) names in
/// `/Popup`, when it is the comment's: a pop-up annotation whose `/Parent`, if it has one, is a
/// reference to the comment. A `/Popup` naming another annotation, or a pop-up of another
/// comment, isn't. ISO 32000-2:2020 §12.5.6.14, Table 186: `/Parent` is "(Optional; shall be
/// an indirect reference)".
pub(crate) fn own_popup(doc: &Document, own: Option<ObjRef>, annot: &Dict) -> Option<ObjRef> {
let pr = annot.reference(b"Popup")?;
let is_own = doc.get(pr).as_dict().is_some_and(|pd| {
pd.name(b"Subtype") == Some(b"Popup")
&& pd
.get(b"Parent")
.is_none_or(|parent| parent.as_ref().is_some_and(|parent| own.is_some_and(|own| final_ref(doc, parent) == final_ref(doc, own))))
});
is_own.then_some(pr)
}
/// The comment's own pop-up (see [`own_popup`]), as [`PopupInfo`]: `None` when it has none or it
/// is unreadable.
pub(crate) fn popup_info(doc: &Document, own: Option<ObjRef>, annot: &Dict) -> Option<PopupInfo> {
// Another annotation's rectangle and open state aren't this comment's (review on #2137).
let r = own_popup(doc, own, annot)?;
let p = doc.get(r);
let p = p.as_dict()?;
let rect: Vec<f32> = doc.resolve(p.get(b"Rect")?).as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64().map(|x| x as f32)).collect();
// Four numbers that are finite as `f32` (a huge one becomes infinite), corners in any order.
let [x0, y0, x1, y1] = rect[..] else { return None };
if !rect.iter().all(|v| v.is_finite()) {
return None;
}
let open = p.get(b"Open").map(|o| doc.resolve(o)).is_some_and(|o| matches!(&*o, Object::Bool(true)));
Some(PopupInfo { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)], open })
}
pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
@@ -64,7 +123,7 @@ pub fn is_comment_subtype(subtype: &str) -> bool {
/// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect`
/// follows it, within a few steps (a cycle stops there).
fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
pub(crate) fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
for _ in 0..8 {
match &*doc.get(r) {
Object::Ref(next) => r = *next,
@@ -74,6 +133,15 @@ fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
r
}
/// Whether an annotation is a subordinate of a group rather than a reply: `/RT /Group` with an
/// `/IRT` reference (ISO 32000-2 §12.5.6.2, Table 172). The group's primary is the `/IRT`
/// annotation, and the subordinate's own text, author, colour, dates and pop-up are ignored.
/// `/RT` is `R` by default, and a value other than `Group` reads as `R` too. Keep in step with
/// `pdfcraft_render::inspect`.
pub fn is_group_subordinate(d: &Dict) -> bool {
d.name(b"RT") == Some(b"Group") && d.reference(b"IRT").is_some()
}
/// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge.
pub fn summaries(doc: &Document) -> Vec<Summary> {
let mut out = Vec::new();
@@ -130,15 +198,29 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|q| q.map(|v| v as f32)).collect()
})
.unwrap_or_default();
// Keep in step with `pdfcraft_render::inspect`.
// Keep in step with `pdfcraft_render::inspect`: a callout gives its points only with a
// readable text box, without which they can't be changed either.
let callout = (subtype == "FreeText").then(|| callout_line(doc, d).zip(callout_text_box(doc, d))).flatten();
let points = match subtype.as_str() {
"Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(),
"PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(),
"FreeText" => callout
.as_ref()
.and_then(|(line, _)| line.split_last())
.map(|(_, lead)| lead.iter().map(|p| [p[0] as f32, p[1] as f32]).collect())
.unwrap_or_default(),
_ => Vec::new(),
};
let text_box = callout.map(|(_, t)| t.map(|v| v as f32));
let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r))));
let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied());
// A group is one page's, as Acrobat reads it: a subordinate whose primary is on
// another page is a comment of its own (keep in step with
// `pdfcraft_render::link_replies`).
let grouped = d.name(b"RT") == Some(b"Group");
let cross_page = grouped && parent.is_some_and(|(p, _)| p != page);
let (parent, in_reply_to) = if cross_page { (None, None) } else { (parent, in_reply_to) };
out.push(Summary {
page,
index,
@@ -151,7 +233,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
name: text_value(doc, d, b"NM"),
in_reply_to,
parent,
grouped: d.name(b"RT") == Some(b"Group"),
grouped,
rect,
color,
// A reply that gives only `/StateModel` has the model's default state (ISO 32000-2
@@ -167,6 +249,8 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
fill_sign: is_fill_sign(doc, d),
no_rotate: drawn_upright(doc, d),
points,
text_box,
popup: popup_info(doc, entry.as_ref().map(|r| final_ref(doc, r)), d),
});
}
}
@@ -256,10 +340,18 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
let direct = crate::draw::with_direct_values(doc, raw);
let d = direct.as_ref();
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { drawn_color(d, b"C") };
if !is_comment_subtype(&subtype) {
return None;
}
// A text box reports the colour and size it is drawn in (`/DA`, or `/DS` where it sets one).
let style = (subtype == "FreeText").then(|| text_style::text_style(d, None));
let color = match &style {
Some(s) => Some(s.color),
None => drawn_color(d, b"C"),
};
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| {
if subtype == "FreeText" {
if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) }
appearance::border_width(d)
} else {
d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)
}
@@ -276,7 +368,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
fillable: fillable(&subtype),
fill: fill_of(d),
font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1),
font_size: style.map(|s| s.size),
border_color: (subtype == "FreeText").then(|| appearance::da_border(d)).flatten(),
border_shape: (subtype == "FreeText" && !d.contains(b"CL")).then(|| BorderShape::of(d)),
fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill),
+9 -5
View File
@@ -29,9 +29,12 @@ mod types;
#[cfg(test)]
use create::num_array;
pub use create::{add_annotation, callout_attach, orient_image_stamp};
pub use create::{
CALLOUT_KNEE_LINE, CUSTOM_LINE_BAND, CUSTOM_LINE_TEXT, Side, add_annotation, callout_attach, callout_knee, callout_knee_on, callout_side,
custom_stamp_size, orient_image_stamp,
};
pub use draw::{AppearanceCache, set_appearance, with_missing_appearances, with_missing_appearances_cached};
pub use info::{Props, Summary, is_comment_subtype, props, set_info, summaries};
pub use info::{PopupInfo, Props, Summary, is_comment_subtype, is_group_subordinate, props, set_info, summaries};
#[cfg(test)]
use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref};
use lookup::{annots, page_ref};
@@ -41,9 +44,9 @@ pub use lookup::{
pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations};
pub(crate) use modify::n;
pub use modify::{
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation,
set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect,
set_review_state, set_style, with_replies_and_popups,
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, release_members,
set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_popup,
set_rect, set_review_state, set_style, with_replies_and_popups,
};
pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model};
pub use types::{
@@ -53,6 +56,7 @@ pub use types::{
pub mod appearance;
pub mod links;
pub mod text_style;
#[cfg(test)]
mod tests;
+12 -5
View File
@@ -1,4 +1,4 @@
use super::info::text_value;
use super::info::{is_comment_subtype, text_value};
use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape};
use super::{AnnotError, Dict, Document, ObjRef, Object, appearance};
@@ -106,16 +106,23 @@ pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec<Object>) -> Result
Ok(())
}
/// The annotation at `(page, index)` as an indirect object (inline dictionaries are promoted).
/// Whether the annotation dictionary `d` has a comment `/Subtype` ([`is_comment_subtype`]).
pub(crate) fn is_comment_dict(d: &Dict) -> bool {
d.name(b"Subtype").is_some_and(|s| is_comment_subtype(&String::from_utf8_lossy(s)))
}
/// The comment annotation at `(page, index)` as an indirect object (inline dictionaries are promoted).
pub(crate) fn annot_ref(doc: &mut Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> {
let p = page_ref(doc, page)?;
let mut list = annots(doc, p);
let entry = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let r = match entry {
Object::Ref(r) if doc.get(r).as_dict().is_some() => r,
Object::Dict(d) => {
Object::Ref(r) if doc.get(r).as_dict().is_some_and(is_comment_dict) => r,
Object::Dict(d) if is_comment_dict(&d) => {
let r = doc.add(Object::Dict(d));
list[index] = Object::Ref(r);
if let Some(slot) = list.get_mut(index) {
*slot = Object::Ref(r);
}
set_annots(doc, p, list)?;
r
}
+348 -61
View File
@@ -1,15 +1,17 @@
use super::create::{
add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, polygon_rect,
rect_for, rgb,
CALLOUT_SLACK, add_annotation, base_dict, bounds, callout_attach, callout_knee, callout_side, ending_draws, ending_pair, finite, grow,
ink_bounds, normalize, num_array, on_side, polygon_rect, rect_for, rgb,
};
use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
use super::info::{line_endings_of, text_value};
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots};
use super::info::{is_comment_subtype, is_group_subordinate, line_endings_of, own_popup, popup_info, text_value};
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, is_fill_sign, page_ref, set_annots};
use super::text_style::{self, TextStyle};
use super::types::{
BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb,
Shape, Style, StyleChange, TextReplacement,
};
use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance};
use pdfcraft_fonts::Std14;
/// 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.
@@ -44,11 +46,36 @@ pub(crate) fn n(v: f64) -> String {
// ── edits ───────────────────────────────────────────────────────────────────────────────────
/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its
/// plain `/Contents`, `/DA` and `/Q`, so viewers that edit rich text show the same thing.
/// [`rich_text_in`] for a text box whose `/DA` was just written (in Helvetica, as PdfCraft writes it).
pub(crate) fn rich_text(d: &mut Dict) {
let (color, size) = appearance::parse_da(d);
rich_text_in(d, TextStyle { face: Std14::Helvetica, size, color });
}
/// Give a text box the text `style`: `/DA` keeps the font resource it names (`/Helv` when it names
/// none) and the border colour it gives (`RG`, [`appearance::da_border`]) with the new colour and
/// size, and `/DS` and `/RC` carry the face, so the text keeps the face it is drawn in (ISO
/// 32000-2 Table 177).
fn restyle_text(d: &mut Dict, style: TextStyle) {
let border = appearance::da_border(d);
restyle_text_bordered(d, style, border);
}
/// [`restyle_text`] with the border colour `border` (`None`: the text's).
fn restyle_text_bordered(d: &mut Dict, style: TextStyle, border: Option<crate::Rgb>) {
let font = text_style::da_font_name(d).unwrap_or_else(|| "Helv".into());
let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0));
let stroke = border.map(|c| c.map(|x| x.clamp(0.0, 1.0))).map(|[r, g, b]| format!("{} {} {} RG ", n(r), n(g), n(b))).unwrap_or_default();
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg {stroke}/{font} {} Tf", n(r), n(g), n(b), n(style.size)).into_bytes()));
rich_text_in(d, TextStyle { color: [r, g, b], ..style });
}
/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its
/// plain `/Contents`, its text `style` and `/Q`, so viewers that edit rich text show the same thing.
fn rich_text_in(d: &mut Dict, style: TextStyle) {
let text = d.get(b"Contents").and_then(|c| c.as_string()).map(PdfString::to_text).unwrap_or_default();
let (col, size) = appearance::parse_da(d);
let TextStyle { face, size, color: col } = style;
let (family, weight, slant) = text_style::css(face);
let hex = format!(
"#{:02X}{:02X}{:02X}",
(col[0].clamp(0.0, 1.0) * 255.0).round() as u8,
@@ -63,13 +90,15 @@ pub(crate) fn rich_text(d: &mut Dict) {
let esc = |s: &str| s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;");
let body: String = text.split('\n').map(|line| format!("<p dir=\"ltr\">{}</p>", esc(line.trim_end_matches('\r')))).collect();
let style = format!(
"font-size:{}pt;text-align:{align};color:{hex};font-weight:normal;font-style:normal;font-family:Helvetica;font-stretch:normal",
"font-size:{}pt;text-align:{align};color:{hex};font-weight:{weight};font-style:{slant};font-family:{family};font-stretch:normal",
n(size)
);
let rc = format!(
"<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\" xfa:spec=\"2.0.2\" style=\"{style}\">{body}</body>"
);
d.set(b"DS".to_vec(), PdfString::text(&format!("font: Helvetica {}pt; text-align:{align}; color:{hex}", n(size))));
// "font: Helvetica 12pt" for PdfCraft's own boxes; other faces add their slant and weight.
let keywords = format!("{}{}", if slant == "normal" { "" } else { "italic " }, if weight == "normal" { "" } else { "bold " });
d.set(b"DS".to_vec(), PdfString::text(&format!("font: {keywords}{family} {}pt; text-align:{align}; color:{hex}", n(size))));
d.set(b"RC".to_vec(), PdfString::text(&rc));
}
@@ -80,9 +109,14 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) {
}
/// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does.
/// Refused while it, or a reply that would go with it, is locked.
/// Refused while it, or a reply that would go with it, is locked. A member of a group (`/RT
/// /Group`), its primary included, goes alone, as Acrobat deletes the member clicked and keeps the
/// rest of its group, although ISO 32000-2 §12.5.6.2 lists deletion among the operations that act
/// on the entire group; a subordinate left without its primary is a comment of its own and
/// loses its `/IRT` ([`release_members`]). Replace Text's strikeout and caret are one comment and
/// go together from either ([`own_deletion`]).
pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> {
deletable(doc, page, index)?;
let index = deleted_index(doc, page, index)?;
lift_annotation(doc, page, index)
}
@@ -90,23 +124,48 @@ pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Resul
/// changing anything: what a Delete command asks before it is offered. It is refused while the
/// annotation is locked, or a reply that would go with it is.
pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotError> {
deleted_index(doc, page, index).map(|_| ())
}
/// [`deletable`], returning the index [`delete_annotation`] deletes from: the caret for Replace
/// Text's strikeout, else `index`.
fn deleted_index(doc: &Document, page: usize, index: usize) -> Result<usize, AnnotError> {
let list = annots(doc, page_ref(doc, page)?);
let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let mut target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let mut index = index;
let target_obj = target.as_ref().map_or_else(|| doc.resolve(target), |r| doc.get(super::info::final_ref(doc, r)));
if !target_obj.as_dict().is_some_and(super::lookup::is_comment_dict) {
return Err(AnnotError::NoSuchAnnotation { page, index });
}
if let Some(r) = target.as_ref() {
unlocked(doc, r)?;
}
// Replace Text is one comment: its strikeout goes with its caret, the group's primary, and
// the caret with it, as Acrobat deletes the pair from either.
let caret = doc
.resolve(target)
.as_dict()
.filter(|d| is_group_subordinate(d) && !own_deletion(doc, d))
.and_then(|d| d.reference(b"IRT"))
.and_then(|irt| list.iter().enumerate().find(|(_, e)| e.as_ref() == Some(irt)));
if let Some((i, e)) = caret {
(target, index) = (e, i);
}
let doomed = with_replies_and_popups(doc, &list, seed(doc, target));
// A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it
// answers either. Pop-ups follow their comment.
for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) {
// answers either (ISO 32000-2 §12.5.3, Table 167: Locked); nor does the other half of Replace
// Text. Pop-ups follow their comment.
for (i, e) in list.iter().enumerate().filter(|(i, e)| (*i != index || caret.is_some()) && goes_with(doc, e, &doomed)) {
let obj = doc.resolve(e);
if let Some(d) = obj.as_dict().filter(|d| d.name(b"Subtype") != Some(b"Popup".as_slice()))
&& annotation_flags(doc, d) & FLAG_LOCKED != 0
{
return Err(AnnotError::Invalid("a reply to it is locked".into()));
let grouped = is_group_subordinate(d) || caret.is_some_and(|(c, _)| c == i);
let what = if grouped { "a comment in its group is locked" } else { "a reply to it is locked" };
return Err(AnnotError::Invalid(what.into()));
}
}
Ok(())
Ok(index)
}
/// Take the annotation at `(page, index)` out of its page with its pop-up and replies, as
@@ -117,10 +176,42 @@ pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result<
let list = annots(doc, p);
let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let doomed = with_replies_and_popups(doc, &list, seed(doc, target));
let kept: Vec<Object> = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect();
let kept: Vec<Object> = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(doc, e, &doomed)).map(|(_, e)| e.clone()).collect();
let kept = release_members(doc, kept, &doomed)?;
Ok(set_annots(doc, p, kept)?)
}
/// The `/Annots` entries `kept` after deleting `going`, with every group subordinate that stays
/// ([`own_deletion`]) but whose `/IRT` names an annotation going made a comment of its own: its
/// `/IRT` is removed and its `/RT /Group` kept, as Acrobat does when a group's primary is deleted.
/// A deleted primary then stays reachable from nothing, so a full save leaves its text out. An
/// inline entry is rewritten in the list; a referenced one is updated in place. Each entry is
/// read once, following no chain.
pub fn release_members(doc: &mut Document, kept: Vec<Object>, going: &HashSet<ObjRef>) -> Result<Vec<Object>, pdfcraft_cos::CosError> {
let orphaned = |d: &Dict| is_group_subordinate(d) && d.reference(b"IRT").is_some_and(|r| going.contains(&r));
let mut out = Vec::with_capacity(kept.len());
for e in kept {
match e.as_ref() {
Some(r) => {
if doc.get(r).as_dict().is_some_and(orphaned) {
doc.update_dict(r, |d| {
d.remove(b"IRT");
})?;
}
out.push(e);
}
None => match e {
Object::Dict(mut d) if orphaned(&d) => {
d.remove(b"IRT");
out.push(Object::Dict(d));
}
other => out.push(other),
},
}
}
Ok(out)
}
/// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline
/// comment (which has none to collect) the pop-up it names, which still goes. The strikeout of a
/// Replace Text proposal also takes the caret it is grouped under, so the two go together
@@ -144,16 +235,35 @@ fn replaced_by(doc: &Document, strike: &Object) -> Option<ObjRef> {
/// one of them, or it is written inline (a file can, though the standard asks for references) and
/// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference
/// for the walk to collect, so it is matched here.
pub fn goes_with(entry: &Object, going: &HashSet<ObjRef>) -> bool {
pub fn goes_with(doc: &Document, entry: &Object, going: &HashSet<ObjRef>) -> bool {
match entry.as_ref() {
Some(r) => going.contains(&r),
None => entry.as_dict().is_some_and(|d| [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten().any(|r| going.contains(&r))),
None => entry.as_dict().is_some_and(|d| hangs_off(doc, d).into_iter().flatten().any(|r| going.contains(&r))),
}
}
/// What the annotation `d` goes with when it is deleted: what it replies to (`/IRT`) and what it
/// pops up for (`/Parent`). A group subordinate (`/RT /Group`) is deleted on its own
/// ([`own_deletion`]), so its `/IRT` doesn't count.
fn hangs_off(doc: &Document, d: &Dict) -> [Option<ObjRef>; 2] {
[d.reference(b"IRT").filter(|_| !own_deletion(doc, d)), d.reference(b"Parent")]
}
/// Whether the annotation `d` stays when its group's primary is deleted: a group subordinate
/// (`/RT /Group`) does, as Acrobat deletes only the member clicked, unless it is half of Replace
/// Text, which Acrobat deletes as one comment: the strikeout (`/IT /StrikeOutTextEdit`) under its
/// caret, or a caret under such a strikeout, as earlier PdfCraft builds wrote the pair.
fn own_deletion(doc: &Document, d: &Dict) -> bool {
let edit = |d: &Dict| d.name(b"IT") == Some(b"StrikeOutTextEdit".as_slice());
let under_edit =
|| d.name(b"Subtype") == Some(b"Caret".as_slice()) && d.reference(b"IRT").is_some_and(|r| doc.get(r).as_dict().is_some_and(edit));
is_group_subordinate(d) && !edit(d) && !under_edit()
}
/// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or
/// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their
/// own replies, and the pop-ups going annotations name (`/Popup`). An inline reply has no
/// own replies, and the pop-ups going annotations name (`/Popup`). A group subordinate stays
/// ([`own_deletion`]). An inline reply has no
/// reference to collect, but the pop-up it names is. Each annotation is visited once, so a long or
/// cyclic chain costs no more than its length. Filter a list with [`goes_with`], which also
/// matches inline entries.
@@ -165,7 +275,7 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int
// What goes with the annotation this entry hangs off: the entry, or for an inline one the
// pop-up it names.
let Some(goes) = e.as_ref().or_else(|| d.reference(b"Popup")) else { continue };
for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() {
for on in hangs_off(doc, d).into_iter().flatten() {
hanging.entry(on).or_default().push(goes);
}
}
@@ -186,15 +296,17 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int
/// around its text stays snug around the new text, centred where it was.
pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
let d = annot_dict(doc, r);
let free_text = d.name(b"Subtype") == Some(b"FreeText");
let before = annot_dict(doc, r);
let free_text = before.name(b"Subtype") == Some(b"FreeText");
refuse_undrawable_thai(free_text, text)?;
let snug = free_text && is_snug(&d);
let snug = free_text && is_snug(&before);
// The text keeps the look it is drawn with (`/DA`, or `/DS` where it sets one).
let style = text_style::text_style(&before, text_style::dr_face(doc, &before));
doc.update_dict(r, |d| {
d.set(b"Contents".to_vec(), PdfString::text(text));
// The rich-text version follows the new plain text (text boxes), or would contradict it.
if free_text {
rich_text(d);
rich_text_in(d, style);
} else {
d.remove(b"RC");
}
@@ -227,7 +339,9 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m
fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
let d = annot_dict(doc, r);
let nums = |key: &[u8]| -> Option<Vec<f64>> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() };
let (_, size) = appearance::parse_da(&d);
// Measured in the face and size the text is drawn in (`/DA`, or `/DS` where it sets one).
let TextStyle { face, size, .. } = text_style::text_style(&d, text_style::dr_face(doc, &d));
let measure = |s: &str, size: f64| appearance::text_width_in(face, s, size);
let pad = 2.0 + border_width_of(&d);
let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?;
let rect = [rect[0], rect[1], rect[2], rect[3]];
@@ -245,10 +359,10 @@ fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
w
} else {
// No usable old width: fall back to the creation-time width (at most 300 pt).
let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
let longest = text.lines().map(|l| measure(l, size)).fold(0.0, f64::max);
(longest + 2.0 * pad + 4.0).clamp(40.0, 300.0)
};
let height = appearance::text_lines_height(&appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)), size);
let height = appearance::text_lines_height(&pdfcraft_fonts::wrap_with(text, size, (w - 2.0 * pad).max(1.0), measure), size);
Some([x0, top - height - 2.0 * pad - 2.0, x0 + w, top])
}
@@ -328,6 +442,83 @@ pub(crate) fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> {
if flags(doc, r) & FLAG_LOCKED != 0 { Err(AnnotError::Invalid("the comment is locked".into())) } else { Ok(()) }
}
/// Open, close or move a comment's pop-up window (Acrobat: double-click a comment to open it,
/// drag its title bar to move it). `rect` is the pop-up's new `/Rect` in user space; `open` its
/// `/Open`. A comment without a `/Popup` gets one, placed to the right of the comment as new
/// notes get theirs. A text box, a Fill & Sign mark or a reply has no pop-up of its own (a reply
/// shows in its parent's). The lock does not apply: the window's place and state are not the
/// comment. The modification date is left alone: nothing of the comment changed.
pub fn set_popup(doc: &mut Document, page: usize, index: usize, rect: Option<[f64; 4]>, open: Option<bool>, meta: &Meta) -> Result<(), AnnotError> {
let (p, r) = annot_ref(doc, page, index)?;
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
if !is_comment_subtype(&subtype) || subtype == "FreeText" || is_fill_sign(doc, &d) || d.contains(b"IRT") {
return Err(AnnotError::Invalid(format!("{subtype} comments have no pop-up of their own")));
}
let rect = match rect {
Some(rect) => {
let rect = normalize(rect);
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid pop-up rectangle".into()));
}
Some(rect)
}
None => None,
};
let _ = meta;
// Where a new pop-up goes: to the right of the comment, as new notes' do.
let own: Vec<f64> = d
.get(b"Rect")
.map(|o| doc.resolve(o))
.and_then(|o| o.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|v| doc.resolve(v).as_f64())
.collect();
let beside = match own[..] {
[_, _, x1, y1] if finite(&own) => [x1 + 10.0, y1 - 120.0, x1 + 210.0, y1],
_ => [0.0, 0.0, 200.0, 120.0],
};
// Only a pop-up that is this comment's: `/Popup` naming another annotation (or a pop-up
// whose `/Parent` is another comment) would move or open that one, so it gets its own.
match own_popup(doc, Some(r), &d) {
Some(pr) => {
// One without a `/Rect` of four numbers reads as no pop-up (`summaries`), so it gets
// the new pop-up's place: opening it then shows it.
let rect = rect.or_else(|| popup_info(doc, Some(r), &d).is_none().then_some(beside));
doc.update_dict(pr, |p| {
if let Some(rect) = rect {
p.set(b"Rect".to_vec(), num_array(&rect));
}
if let Some(open) = open {
p.set(b"Open".to_vec(), Object::Bool(open));
}
})?
}
None => {
let mut pd = Dict::new();
pd.set(b"Type".to_vec(), Object::name("Annot"));
pd.set(b"Subtype".to_vec(), Object::name("Popup"));
pd.set(b"Rect".to_vec(), num_array(&rect.unwrap_or(beside)));
pd.set(b"Parent".to_vec(), Object::Ref(r));
pd.set(b"Open".to_vec(), Object::Bool(open.unwrap_or(false)));
pd.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
let pref = doc.add(Object::Dict(pd));
doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?;
let mut list = annots(doc, p);
list.push(Object::Ref(pref));
set_annots(doc, p, list)?;
}
}
// A note's own `/Open` says the same thing to viewers that read it there (§12.5.6.4).
if let Some(open) = open
&& (subtype == "Text" || d.contains(b"Open"))
{
doc.update_dict(r, |d| d.set(b"Open".to_vec(), Object::Bool(open)))?;
}
Ok(())
}
/// Lock or unlock a comment (the Locked flag). A locked comment can't be moved, resized,
/// restyled or deleted; its text and replies stay editable, as in Acrobat.
pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> {
@@ -612,10 +803,12 @@ pub fn set_border_shape(doc: &mut Document, page: usize, index: usize, shape: Bo
/// At most this many points reshape a comment (a drawn polygon has a handful).
const MAX_POINTS: usize = 100_000;
/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two
/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the
/// points with room for the line's width and endings, as when the comment was drawn, and the
/// appearance is redrawn. A measurement keeps the points it measured.
/// Reshape a line, connected lines, a polygon, a cloud or a callout: its points in user space (a
/// line's two ends, the others' corners; a callout's arrow point and, if it has one, its knee), as
/// dragging the comment's handles sets them. `/Rect` follows the points with room for the line's
/// width and endings, as when the comment was drawn, and the appearance is redrawn. A callout's
/// text box stays where it is and its leader line re-attaches. A measurement keeps the points it
/// measured.
pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
@@ -626,6 +819,15 @@ pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64;
if points.len() > MAX_POINTS {
return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points")));
}
if d.name(b"Subtype") == Some(b"FreeText") && callout_line(doc, &d).is_some() {
// A callout's points: its arrow point, then optionally its knee (a four-number `/CL`
// gains one; ISO 32000-2 Table 177 allows both forms). The arrow point alone keeps a knee.
if !matches!(points.len(), 1 | 2) || !finite(&points.concat()) {
return Err(AnnotError::Invalid("a callout has an arrow point and, optionally, a knee".into()));
}
let text_box = callout_text_box(doc, &d).ok_or_else(|| AnnotError::Invalid("the callout's text box (/RD) can't be read".into()))?;
return place_text_box(doc, r, text_box, Some(points), meta, false);
}
// The width the appearance draws with (`/BS`, else `/Border`), so the room is the drawn stroke's.
let style = Style { width: appearance::border_width(&d), ..Style::default() };
let ends = line_endings_of(&d).unwrap_or_default();
@@ -700,6 +902,43 @@ fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Res
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn
/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`).
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> {
place_text_box(doc, r, rect, None, meta, new_text)
}
/// A callout's leader line (`/CL`, ISO 32000-2 Table 177): four numbers (start, end) or six
/// (start, knee, end), as points in user space. `None` when the annotation has no usable `/CL`.
pub(crate) fn callout_line(doc: &Document, d: &Dict) -> Option<Vec<[f64; 2]>> {
// All or nothing: a non-number would shift every later number into the wrong point.
let l: Vec<f64> = doc.resolve(d.get(b"CL")?).as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect::<Option<_>>()?;
(matches!(l.len(), 4 | 6) && finite(&l)).then(|| l.as_chunks::<2>().0.to_vec())
}
/// A callout's text box: `/Rect` inset by `/RD` (ISO 32000-2 Table 177). `None` without both.
pub(crate) fn callout_text_box(doc: &Document, d: &Dict) -> Option<[f64; 4]> {
let nums = |key: &[u8]| -> Option<Vec<f64>> { doc.resolve(d.get(key)?).as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect() };
let r = nums(b"Rect").filter(|v| v.len() == 4 && finite(v))?;
let rd = nums(b"RD").filter(|v| v.len() == 4 && finite(v) && v.iter().all(|x| *x >= 0.0))?;
let r = normalize([r[0], r[1], r[2], r[3]]);
let tb = [r[0] + rd[0], r[1] + rd[1], r[2] - rd[2], r[3] - rd[3]];
(tb[2] - tb[0] >= 1.0 && tb[3] - tb[1] >= 1.0).then_some(tb)
}
/// As [`apply_text_box`]. For a callout, `lead` (its arrow point and optional knee, dragged by
/// their handles) replaces the leader line's own points. The arrow point never moves with the box
/// ("The text box moves around a stationary anchor point—the arrow on the end-point line",
/// Acrobat DC Help, 11/28/2019, p. 357), and the line follows the box as Acrobat Pro DC (a 2021
/// Continuous release) was seen to:
/// - the box moved without changing size: the knee and the end move with it (also seen in
/// Acrobat Pro 2026.002.22031);
/// - the box resized with the side the line meets left where it was (the end still on it): the
/// line doesn't change;
/// - otherwise (that side moved): the line meets the middle of that side, the knee
/// [`CALLOUT_KNEE_LINE`] out from it ([`callout_knee_on`]). Acrobat also moves the box next to
/// the arrow point then; PdfCraft leaves the box where it was put.
///
/// With `lead`, the end stays where it is while it is on the side facing the knee (dragging the
/// knee or the arrow point leaves it in Acrobat), and otherwise meets the middle of that side.
fn place_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], lead: Option<&[[f64; 2]]>, meta: &Meta, new_text: bool) -> Result<(), AnnotError> {
let rect = normalize(rect);
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
@@ -708,17 +947,62 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
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 {
Some(mut l) => {
let attach = callout_attach(rect, [l[2], l[3]]);
(l[4], l[5]) = (attach[0], attach[1]);
let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0;
let lead = grow(bounds([[l[0], l[1]], [l[2], l[3]]].into_iter()).unwrap_or_default(), pad);
([rect[0].min(lead[0]), rect[1].min(lead[1]), rect[2].max(lead[2]), rect[3].max(lead[3])], Some(l))
// A callout's rectangle is its text box: `/Rect` grows to hold the leader line. A four-number
// `/CL` (no knee) stays four numbers unless `lead` adds a knee.
let old_line = callout_line(doc, &d);
let old_box = callout_text_box(doc, &d);
let old_end = old_line.as_deref().and_then(<[[f64; 2]]>::last).copied();
// An arrow point alone moves the arrow: a knee the line has stays where it is.
let kept: [[f64; 2]; 2];
let lead = match (lead, old_line.as_deref()) {
(Some([point]), Some([_, knee, _])) => {
kept = [*point, *knee];
Some(&kept[..])
}
None => (rect, None),
_ => lead,
};
let line: Option<Vec<[f64; 2]>> = match (lead, old_line.as_deref()) {
(Some(lead @ [.., toward]), _) => {
let side = callout_side(rect, *toward);
let end = old_end.filter(|e| on_side(rect, side, *e)).unwrap_or_else(|| callout_attach(rect, *toward));
Some(lead.iter().copied().chain([end]).collect())
}
(Some(_), _) => None,
(None, Some(old @ [start, .., end])) => {
let (start, end) = (*start, *end);
let knee = old.get(1).filter(|_| old.len() == 3).copied();
let toward = knee.unwrap_or(start);
Some(match old_box {
// Moved, not resized: the knee and the end come along.
Some(b)
if ((rect[2] - rect[0]) - (b[2] - b[0])).abs() < CALLOUT_SLACK && ((rect[3] - rect[1]) - (b[3] - b[1])).abs() < CALLOUT_SLACK =>
{
let (dx, dy) = (rect[0] - b[0], rect[1] - b[1]);
let shift = |p: [f64; 2]| [p[0] + dx, p[1] + dy];
[start].into_iter().chain(knee.map(shift)).chain([shift(end)]).collect()
}
// Resized, the side the line meets still under its end: nothing changes.
Some(b) if on_side(rect, callout_side(b, toward), end) => old.to_vec(),
// That side moved: laid out again as a new callout is, from the side facing the
// arrow point (the box may now be on the knee's other side).
Some(_) => {
let new_knee = callout_knee(rect, start);
let end = callout_attach(rect, new_knee);
[start].into_iter().chain(knee.map(|_| new_knee)).chain([end]).collect()
}
None => [start].into_iter().chain(knee).chain([callout_attach(rect, toward)]).collect(),
})
}
(None, _) => None,
};
let (outer, cl) = match line.as_deref() {
Some(line @ [lead @ .., _]) => {
// The end is on the box, so only the arrow point and the knee need room around them.
let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0;
let lead_box = grow(bounds(lead.iter().copied()).unwrap_or_default(), pad);
([rect[0].min(lead_box[0]), rect[1].min(lead_box[1]), rect[2].max(lead_box[2]), rect[3].max(lead_box[3])], Some(line.concat()))
}
_ => (rect, None),
};
let (shape, width) = (BorderShape::of(&d), border_width_of(&d));
let free_text = d.name(b"Subtype") == Some(b"FreeText");
@@ -749,8 +1033,9 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
}
}
/// The border width as the appearance draws it: `/BS /W`, else the third number of `/Border`
/// (another app's), else 1.
/// The width a comment's border is drawn with ([`appearance::border_width`]: `/BS /W`, else
/// `/Border`'s third number, 1 when neither gives a usable one), so what is sized around it
/// leaves room for what is drawn.
fn border_width_of(d: &Dict) -> f64 {
appearance::border_width(d)
}
@@ -857,6 +1142,8 @@ fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) ->
}
refuse_foreign_text(doc, &d)?;
let free_text = subtype == "FreeText";
// The face and size the text is drawn in, which the new colour keeps.
let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d));
// Typewriter text has no box of its own: a border goes around its text, so the box grows
// (or shrinks) by the change on every side and the text keeps its place and its lines.
// So does a text box inside an oval or a circle, which goes around it again.
@@ -878,9 +1165,7 @@ fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) ->
}
if let Some(c) = color {
if free_text {
let (_, size) = appearance::parse_da(d);
d.set(b"DA".to_vec(), free_text_da(c, appearance::da_border(d), size));
rich_text(d);
restyle_text(d, TextStyle { color: c, ..drawn });
} else {
d.set(b"C".to_vec(), rgb(c));
}
@@ -939,11 +1224,11 @@ pub fn set_border_color(doc: &mut Document, page: usize, index: usize, color: Op
return Err(AnnotError::Invalid("invalid colour".into()));
}
refuse_foreign_text(doc, &d)?;
// The face, size and colour the text is drawn in, which the border colour keeps.
let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d));
let before = d;
doc.update_dict(r, |d| {
let (text, size) = appearance::parse_da(d);
d.set(b"DA".to_vec(), free_text_da(text, color, size));
rich_text(d);
restyle_text_bordered(d, drawn, color);
touch(d, meta);
})?;
redraw_or_restore(doc, r, before)
@@ -964,11 +1249,11 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m
}
refuse_foreign_text(doc, &d)?;
let snug = is_snug(&d);
// The face and colour the text is drawn in, which the new size keeps.
let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d));
let before = d;
doc.update_dict(r, |d| {
let (col, _) = appearance::parse_da(d);
d.set(b"DA".to_vec(), free_text_da(col, appearance::da_border(d), size));
rich_text(d);
restyle_text(d, TextStyle { size, ..drawn });
touch(d, meta);
})?;
let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default();
@@ -1120,16 +1405,17 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option<Vec<[f64; 2]>> {
}
/// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the
/// struck text holding `replacement`, the two grouped so they move, list and delete as one.
/// Returns the strikeout's index.
/// struck text holding `replacement`, grouped (ISO 32000-2 §12.5.6.2) so they list, select and
/// delete as one. Returns the caret's index, the group's comment.
///
/// The caret is the group's primary (`/IT /Replace`) and the strikeout its subordinate (`/IRT` to
/// the caret, `/RT /Group`, `/IT /StrikeOutTextEdit`): a group's text is read from its primary,
/// and the subordinates' `Contents` are ignored (ISO 32000-2 §12.5.6.2), so the replacement has
/// to be on the primary.
/// The caret is the group's primary: it holds `replacement` in `/Contents` and has `/IT
/// /Replace`. The strikeout is its subordinate (`/IRT` to the caret, `/RT /Group`, `/IT
/// /StrikeOutTextEdit`) with no `/Contents`, since a reader ignores a subordinate's text.
/// Groups written before this the other way round (the strikeout primary, the text only on
/// the caret) still list their text: see `pdfcraft_render::fill_group_text`.
///
/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies
/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that
/// to, so one id for both writes `inreplyto` equal to the strikeout's own `name`, and a reader that
/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id
/// from the caller, so the edit stays deterministic.
pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result<usize, AnnotError> {
@@ -1168,8 +1454,9 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>
d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit"));
d.set(b"IRT".to_vec(), Object::Ref(cr));
d.set(b"RT".to_vec(), Object::name("Group"));
d.remove(b"Contents");
})?;
Ok(s)
Ok(c)
}
/// The Eraser (Draw tools): remove the parts of drawing `(page, index)` within `radius` points
+1239 -24
View File
File diff suppressed because it is too large. Load diff
+480
View File
@@ -0,0 +1,480 @@
//! The face, size and colour a text box's text is drawn in when PdfCraft draws its appearance:
//! `/DA` (ISO 32000-2 Table 177; the font named there is looked up in the interactive form's
//! `/DR` font resources, §12.7.4.3) and `/DS`, the default style string (Table 177), which is
//! read for its CSS `font`, `font-family`, `font-size`, `font-weight`, `font-style` and `color`.
//!
//! Only the twelve standard text faces are drawn (Helvetica, Times and Courier, each regular,
//! bold, italic and bold italic, in WinAnsiEncoding): a font that isn't one of them is drawn in
//! Helvetica, as before. Symbol and ZapfDingbats are not text faces for this purpose.
use pdfcraft_cos::{Dict, Document, Object};
use pdfcraft_fonts::{Std14, Std14Match as Match};
use crate::Rgb;
use crate::appearance::parse_da;
/// `/DS` past this many bytes is read only this far (a style string is a line or two).
const MAX_DS: usize = 4096;
/// Font sizes a text box is drawn at, as for `/DA`.
const MAX_SIZE: f64 = 400.0;
/// How a text box's text is drawn.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextStyle {
pub face: Std14,
pub size: f64,
pub color: Rgb,
}
/// The style of `d`'s text: `/DA`, with what `/DS` sets taking its place. `dr_face` is the face
/// the `/DA` font resource names in the form's `/DR` ([`dr_face`]); `None` when it isn't there,
/// and the common resource names (`/Helv`, `/Cour`, `/TiRo`, …) are read instead.
pub fn text_style(d: &Dict, dr_face: Option<Std14>) -> TextStyle {
let (da_color, da_size) = parse_da(d);
let da_face = dr_face.or_else(|| da_font_name(d).as_deref().and_then(resource_face)).unwrap_or(Std14::Helvetica);
let mut style = TextStyle { face: da_face, size: da_size, color: da_color };
let Some(ds) = d.get(b"DS").and_then(Object::as_string).map(|s| s.to_text()) else { return style };
let ds = parse_ds(&ds);
if let Some(size) = ds.size {
style.size = size;
}
// `/DS` colours are 8-bit (`#RRGGBB`): one that rounds to the same as `/DA`'s keeps `/DA`'s.
if let Some(color) = ds.color
&& bytes(color) != bytes(da_color)
{
style.color = color;
}
let base = ds.face.unwrap_or(style.face);
style.face = styled(base, ds.bold.unwrap_or(is_bold(base)), ds.italic.unwrap_or(is_italic(base)));
style
}
/// The face the `/DA` font of `d` names in the document's `/AcroForm /DR /Font` (§12.7.4.3):
/// `Some` when that resource is there — the standard text face its `/BaseFont` names, or
/// Helvetica for any other font — and `None` when it isn't.
pub fn dr_face(doc: &Document, d: &Dict) -> Option<Std14> {
let name = da_font_name(d)?;
let root = doc.trailer().get(b"Root").map(|r| doc.resolve(r))?;
let form = root.as_dict()?.get(b"AcroForm").map(|f| doc.resolve(f))?;
let dr = form.as_dict()?.get(b"DR").map(|r| doc.resolve(r))?;
let fonts = dr.as_dict()?.get(b"Font").map(|f| doc.resolve(f))?;
let font = fonts.as_dict()?.get(name.as_bytes()).map(|f| doc.resolve(f))?;
let font = font.as_dict()?;
let base = font.name(b"BaseFont").map(String::from_utf8_lossy);
Some(base.as_deref().and_then(base_font_face).unwrap_or(Std14::Helvetica))
}
/// The font resource name of the last `Tf` in `/DA` (without the slash). `Tf` takes a name and
/// a size; a name given without its size (`/TiRo Tf`) is still read, and the size then falls back
/// as [`parse_da`] says.
pub fn da_font_name(d: &Dict) -> Option<String> {
let da = d.get(b"DA").and_then(Object::as_string)?;
let da = String::from_utf8_lossy(&da.bytes);
let toks: Vec<&str> = da.split_whitespace().collect();
let tf = toks.iter().rposition(|t| *t == "Tf")?;
let name = match toks.get(..tf)? {
[.., name, size] if !size.starts_with('/') => name,
[.., name] => name,
[] => return None,
};
let name = name.strip_prefix('/')?;
(!name.is_empty()).then(|| name.to_string())
}
/// The face a font resource name stands for: the names PDF writers commonly give the standard
/// fonts in `/DR` (`Helv`, `HeBo`, `Cour`, `TiRo`, …), or a standard face's own name.
pub fn resource_face(name: &str) -> Option<Std14> {
Some(match name {
"Helv" => Std14::Helvetica,
"HeBo" => Std14::HelveticaBold,
"HeOb" | "HeIt" => Std14::HelveticaOblique,
"HeBO" | "HeBI" => Std14::HelveticaBoldOblique,
"Cour" => Std14::Courier,
"CoBo" => Std14::CourierBold,
"CoOb" | "CoIt" => Std14::CourierOblique,
"CoBO" | "CoBI" => Std14::CourierBoldOblique,
"TiRo" => Std14::TimesRoman,
"TiBo" => Std14::TimesBold,
"TiIt" => Std14::TimesItalic,
"TiBI" => Std14::TimesBoldItalic,
_ => return base_font_face(name),
})
}
/// The standard text face a `/BaseFont` (or family) name asks for: the spec's names and their
/// usual aliases (Arial, Times New Roman, Courier New), not a guess from a family hint.
fn base_font_face(name: &str) -> Option<Std14> {
match Std14::recognise(name)? {
(Std14::Symbol | Std14::ZapfDingbats, _) | (_, Match::FamilyHint) => None,
(face, Match::Alias) => Some(face),
}
}
/// `CSS` family names: the generic ones, else a standard face's name or alias.
fn family_face(name: &str) -> Option<Std14> {
let name = name.trim().trim_matches(|c| c == '"' || c == '\'').trim();
match name.to_ascii_lowercase().as_str() {
"serif" => Some(Std14::TimesRoman),
"monospace" => Some(Std14::Courier),
"sans-serif" => Some(Std14::Helvetica),
_ => base_font_face(name),
}
}
fn is_bold(f: Std14) -> bool {
matches!(
f,
Std14::HelveticaBold
| Std14::HelveticaBoldOblique
| Std14::TimesBold
| Std14::TimesBoldItalic
| Std14::CourierBold
| Std14::CourierBoldOblique
)
}
fn is_italic(f: Std14) -> bool {
matches!(
f,
Std14::HelveticaOblique
| Std14::HelveticaBoldOblique
| Std14::TimesItalic
| Std14::TimesBoldItalic
| Std14::CourierOblique
| Std14::CourierBoldOblique
)
}
/// `base`'s family in the given weight and slant.
fn styled(base: Std14, bold: bool, italic: bool) -> Std14 {
use Std14::*;
match (base, bold, italic) {
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, false, false) => TimesRoman,
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, true, false) => TimesBold,
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, false, true) => TimesItalic,
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, true, true) => TimesBoldItalic,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, false, false) => Courier,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, true, false) => CourierBold,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, false, true) => CourierOblique,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, true, true) => CourierBoldOblique,
(_, false, false) => Helvetica,
(_, true, false) => HelveticaBold,
(_, false, true) => HelveticaOblique,
(_, true, true) => HelveticaBoldOblique,
}
}
/// The resource name an appearance stream gives `face` (the usual `/DR` names).
pub fn resource_name(face: Std14) -> &'static str {
match face {
Std14::HelveticaBold => "HeBo",
Std14::HelveticaOblique => "HeOb",
Std14::HelveticaBoldOblique => "HeBO",
Std14::TimesRoman => "TiRo",
Std14::TimesBold => "TiBo",
Std14::TimesItalic => "TiIt",
Std14::TimesBoldItalic => "TiBI",
Std14::Courier => "Cour",
Std14::CourierBold => "CoBo",
Std14::CourierOblique => "CoOb",
Std14::CourierBoldOblique => "CoBO",
_ => "Helv",
}
}
/// `face`'s `CSS` family, weight and style, for writing `/DS` and `/RC`.
pub fn css(face: Std14) -> (&'static str, &'static str, &'static str) {
let family = match styled(face, false, false) {
Std14::TimesRoman => "Times",
Std14::Courier => "Courier",
_ => "Helvetica",
};
(family, if is_bold(face) { "bold" } else { "normal" }, if is_italic(face) { "italic" } else { "normal" })
}
fn bytes(c: Rgb) -> [u8; 3] {
// Clamped to 0..=1 first, so the product is 0..=255 and the cast exact.
c.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8)
}
/// What a `/DS` string sets.
#[derive(Debug, Default, PartialEq)]
struct Ds {
face: Option<Std14>,
bold: Option<bool>,
italic: Option<bool>,
size: Option<f64>,
color: Option<Rgb>,
}
/// Read a default style string: `;`-separated `CSS` declarations. Unknown or malformed ones are
/// skipped; later ones win.
fn parse_ds(ds: &str) -> Ds {
let end = ds.char_indices().map(|(i, _)| i).find(|i| *i >= MAX_DS).unwrap_or(ds.len());
let ds = ds.get(..end).unwrap_or_default();
let mut out = Ds::default();
for decl in ds.split(';') {
let Some((prop, value)) = decl.split_once(':') else { continue };
let value = value.trim();
match prop.trim().to_ascii_lowercase().as_str() {
"font" => font_shorthand(value, &mut out),
"font-family" => {
if let Some(face) = value.split(',').find_map(family_face) {
out.face = Some(face);
}
}
"font-size" => {
if let Some(size) = font_size(value, true) {
out.size = Some(size);
}
}
"font-weight" => {
if let Some(bold) = weight(value) {
out.bold = Some(bold);
}
}
"font-style" => {
if let Some(italic) = slant(value) {
out.italic = Some(italic);
}
}
"color" => {
if let Some(c) = css_color(value) {
out.color = Some(c);
}
}
_ => {}
}
}
out
}
/// `font: [style] [weight] size[/line-height] family[, family…]`, read leniently: the size is
/// the token with a unit, keywords set weight and slant, and the rest is the family list (some
/// writers put the family first).
fn font_shorthand(value: &str, out: &mut Ds) {
let mut family = Vec::new();
for tok in value.split_whitespace() {
if let Some(size) = font_size(tok, false) {
out.size = Some(size);
} else if let Some(bold) = weight(tok).filter(|_| !tok.eq_ignore_ascii_case("normal")) {
out.bold = Some(bold);
} else if let Some(italic) = slant(tok).filter(|_| !tok.eq_ignore_ascii_case("normal")) {
out.italic = Some(italic);
} else if !matches!(tok.to_ascii_lowercase().as_str(), "normal" | "small-caps") {
family.push(tok);
}
}
if let Some(face) = family.join(" ").split(',').find_map(family_face) {
out.face = Some(face);
}
}
/// A size in points: `14pt` (with a line height, `14pt/16pt`), or a bare number where
/// `unitless` allows it. Positive and finite only; capped as `/DA` sizes are.
fn font_size(tok: &str, unitless: bool) -> Option<f64> {
let tok = tok.split('/').next()?.trim();
let num = match tok.strip_suffix("pt").or_else(|| tok.strip_suffix("PT")) {
Some(n) => n,
None if unitless => tok,
None => return None,
};
num.parse::<f64>().ok().filter(|s| s.is_finite() && *s > 0.0).map(|s| s.min(MAX_SIZE))
}
fn weight(v: &str) -> Option<bool> {
match v.to_ascii_lowercase().as_str() {
"bold" | "bolder" | "600" | "700" | "800" | "900" => Some(true),
"normal" | "lighter" | "100" | "200" | "300" | "400" | "500" => Some(false),
_ => None,
}
}
fn slant(v: &str) -> Option<bool> {
match v.to_ascii_lowercase().as_str() {
"italic" | "oblique" => Some(true),
"normal" => Some(false),
_ => None,
}
}
/// `#RRGGBB`, `#RGB` or `rgb(r, g, b)` (0–255 or percentages).
fn css_color(v: &str) -> Option<Rgb> {
let v = v.trim();
if let Some(hex) = v.strip_prefix('#') {
let digit = |c: char| c.to_digit(16).map(f64::from);
let ds: Vec<f64> = hex.chars().map(digit).collect::<Option<_>>()?;
return match ds.as_slice() {
[r, g, b] => Some([r * 17.0 / 255.0, g * 17.0 / 255.0, b * 17.0 / 255.0]),
[r1, r0, g1, g0, b1, b0] => Some([(r1 * 16.0 + r0) / 255.0, (g1 * 16.0 + g0) / 255.0, (b1 * 16.0 + b0) / 255.0]),
_ => None,
};
}
let lower = v.to_ascii_lowercase();
let inner = lower.strip_prefix("rgb(")?.strip_suffix(')')?;
let parts: Vec<f64> = inner
.split(',')
.map(|p| {
let p = p.trim();
let x = match p.strip_suffix('%') {
Some(pc) => pc.trim().parse::<f64>().ok()? / 100.0,
None => p.parse::<f64>().ok()? / 255.0,
};
x.is_finite().then(|| x.clamp(0.0, 1.0))
})
.collect::<Option<_>>()?;
match parts.as_slice() {
[r, g, b] => Some([*r, *g, *b]),
_ => None,
}
}
#[cfg(test)]
mod tests {
use pdfcraft_cos::PdfString;
use super::*;
fn dict(da: &str, ds: Option<&str>) -> Dict {
let mut d = Dict::new();
d.set(b"DA".to_vec(), PdfString::literal(da.as_bytes().to_vec()));
if let Some(ds) = ds {
d.set(b"DS".to_vec(), PdfString::text(ds));
}
d
}
#[test]
fn da_resource_names_pick_the_standard_faces() {
assert_eq!(text_style(&dict("0 g /Cour 12 Tf", None), None).face, Std14::Courier);
assert_eq!(text_style(&dict("1 0 0 rg /TiRo 16 Tf", None), None).face, Std14::TimesRoman);
assert_eq!(text_style(&dict("/HeBo 9 Tf 0 g", None), None).face, Std14::HelveticaBold);
assert_eq!(text_style(&dict("/Courier-Bold 9 Tf", None), None).face, Std14::CourierBold);
// Unknown, symbolic and family-hint-only names keep Helvetica.
for da in ["/F1 12 Tf", "/ZaDb 12 Tf", "/Symbol 12 Tf", "/MySerif 12 Tf", "12 Tf", "", "/ Tf"] {
assert_eq!(text_style(&dict(da, None), None).face, Std14::Helvetica, "{da:?}");
}
// A `/DR` lookup outranks the name.
assert_eq!(text_style(&dict("/Cour 12 Tf", None), Some(Std14::TimesRoman)).face, Std14::TimesRoman);
}
#[test]
fn ds_font_size_and_colour_win_over_da() {
let s = text_style(&dict("0 g /Helv 12 Tf", Some("font: Times 14pt; color: #008000")), None);
assert_eq!((s.face, s.size), (Std14::TimesRoman, 14.0));
assert!((s.color[1] - 128.0 / 255.0).abs() < 1e-9 && s.color[0] == 0.0 && s.color[2] == 0.0, "{:?}", s.color);
let s = text_style(
&dict(
"1 0 0 rg /Cour 9 Tf",
Some("font-family: 'Helvetica', sans-serif; font-size: 20; font-weight: bold; font-style: italic; color: rgb(0, 0, 255)"),
),
None,
);
assert_eq!((s.face, s.size, s.color), (Std14::HelveticaBoldOblique, 20.0, [0.0, 0.0, 1.0]));
// CSS order, a line height, a #RGB colour and a generic family.
let s = text_style(&dict("0 g /Helv 12 Tf", Some("font: italic bold 10pt/12pt serif; color:#f00")), None);
assert_eq!((s.face, s.size, s.color), (Std14::TimesBoldItalic, 10.0, [1.0, 0.0, 0.0]));
// Bold from the style keeps the DA family.
assert_eq!(text_style(&dict("/Cour 12 Tf", Some("font-weight:700")), None).face, Std14::CourierBold);
}
/// PdfCraft writes `/DS` from `/DA` (8-bit colour): `/DA`'s exact colour, size and face stay.
#[test]
fn pdfcraft_written_ds_matches_da() {
let s = text_style(&dict("0.1 0.2 0.3 rg /Helv 12.5 Tf", Some("font: Helvetica 12.5pt; text-align:left; color:#1A334D")), None);
assert_eq!(s, TextStyle { face: Std14::Helvetica, size: 12.5, color: [0.1, 0.2, 0.3] });
}
#[test]
fn malformed_ds_falls_back_to_da() {
let da = TextStyle { face: Std14::Courier, size: 9.0, color: [1.0, 0.0, 0.0] };
let huge = format!("color: #00ff00;{}", "x".repeat(1 << 20));
let past_cap = format!("{}; font-size: 30pt", " ".repeat(MAX_DS));
for ds in [
"font-size: NaN",
"font-size: inf",
"font-size: -3pt",
"font-size: 0",
"font: NaNpt Zzz",
"font: 1e999pt",
"color: #12",
"color: #zzzzzz",
"color: rgb(1, 2)",
"color: rgb(NaN, 0, 0)",
"color: #ff0000é",
":::;;;",
"font-family: Wingdings, 'Comic Sans'",
"é: ü; ;",
past_cap.as_str(),
] {
assert_eq!(text_style(&dict("1 0 0 rg /Cour 9 Tf", Some(ds)), None), da, "{ds:?}");
}
// Read up to the cap: what comes first still counts, and nothing past it is scanned.
assert_eq!(text_style(&dict("1 0 0 rg /Cour 9 Tf", Some(&huge)), None).color, [0.0, 1.0, 0.0]);
// Sizes are capped as /DA's are.
assert_eq!(text_style(&dict("/Helv 9 Tf", Some("font-size: 1e300pt")), None).size, MAX_SIZE);
}
/// `/DA` strings as files give them: a `Tf` without a size, a zero size (auto-size in a form
/// field, which a text box doesn't have), and hostile operands. None panics, each keeps what it
/// can read, and nothing that isn't a finite number reaches the style.
#[test]
fn hostile_da_strings_keep_what_they_can() {
let style = |da: &str| text_style(&dict(da, None), None);
let black12 = |face| TextStyle { face, size: 12.0, color: [0.0; 3] };
for (da, want) in [
// A missing size keeps the named face at the default size.
("/TiRo Tf", black12(Std14::TimesRoman)),
("0 g /Cour Tf", black12(Std14::Courier)),
// Zero, negative, NaN, infinite or not a number: the default size.
("/Cour 0 Tf", black12(Std14::Courier)),
("/Cour -0 Tf", black12(Std14::Courier)),
("/Cour -12 Tf", black12(Std14::Courier)),
("/Cour NaN Tf", black12(Std14::Courier)),
("/Cour inf Tf", black12(Std14::Courier)),
("/Cour -inf Tf", black12(Std14::Courier)),
("/Cour 1e999 Tf", black12(Std14::Courier)),
("/Cour twelve Tf", black12(Std14::Courier)),
// No Tf at all, or one with nothing before it.
("1 0 0 rg", TextStyle { color: [1.0, 0.0, 0.0], ..black12(Std14::Helvetica) }),
("Tf", black12(Std14::Helvetica)),
("Tf Tf Tf", black12(Std14::Helvetica)),
("", black12(Std14::Helvetica)),
// Colour operands that aren't finite numbers leave the colour black.
("NaN NaN NaN rg /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
("NaN g /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
("inf 0 0 rg /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
("rg g /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
// Out-of-range colour components are clamped.
("2 -1 0.5 rg /Helv 9 Tf", TextStyle { size: 9.0, color: [1.0, 0.0, 0.5], ..black12(Std14::Helvetica) }),
// Huge sizes are capped.
("/Helv 1e300 Tf", TextStyle { size: MAX_SIZE, ..black12(Std14::Helvetica) }),
("/Helv 100000 Tf", TextStyle { size: MAX_SIZE, ..black12(Std14::Helvetica) }),
// Garbage around a valid Tf: the last Tf is read.
("\u{0} ] [ << /Helv 9 Tf >> /Cour 14 Tf )", TextStyle { size: 14.0, ..black12(Std14::Courier) }),
] {
assert_eq!(style(da), want, "{da:?}");
let s = style(da);
assert!(s.size.is_finite() && s.size > 0.0 && s.color.iter().all(|c| (0.0..=1.0).contains(c)), "{da:?}: {s:?}");
}
// A font name that is no standard face and that no `/DR` lists keeps Helvetica.
assert_eq!(style("0 g /F9 11 Tf"), TextStyle { size: 11.0, ..black12(Std14::Helvetica) });
// Binary bytes that aren't UTF-8 don't panic.
let mut d = Dict::new();
d.set(b"DA".to_vec(), PdfString::literal(vec![0xFF, 0xFE, b' ', b'/', 0xC3, b' ', b'9', b' ', b'T', b'f']));
assert_eq!(text_style(&d, None).size, 9.0);
assert_eq!(da_font_name(&dict("/TiRo Tf", None)).as_deref(), Some("TiRo"));
assert_eq!(da_font_name(&dict("12 Tf", None)), None);
assert_eq!(da_font_name(&dict("0 g Tf", None)), None);
}
#[test]
fn css_round_trips_through_the_parser() {
for face in [Std14::Helvetica, Std14::TimesBold, Std14::CourierOblique, Std14::HelveticaBoldOblique, Std14::TimesBoldItalic] {
let (family, weight, style) = css(face);
let ds = format!("font-family:{family};font-weight:{weight};font-style:{style}");
assert_eq!(text_style(&dict("/Helv 9 Tf", Some(&ds)), None).face, face);
}
}
}
+5 -1
View File
@@ -417,13 +417,17 @@ pub enum Shape {
contours: Vec<Vec<[f64; 2]>>,
},
/// A custom stamp: a picture already in the document (an image XObject, or a form XObject
/// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`.
/// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. With `line`, a
/// line of text under the picture (#1741): a dynamic stamp's name, date and time, filled in
/// when it was placed; the picture then fills the rectangle above it
/// ([`CUSTOM_LINE_BAND`](crate::CUSTOM_LINE_BAND)).
CustomStamp {
rect: [f64; 4],
name: String,
picture: ObjRef,
image: bool,
size: (f64, f64),
line: Option<String>,
},
/// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad
/// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`.
+3 -2
View File
@@ -207,8 +207,9 @@ mutually exclusive with `hide_text_block`.
When the renderer reports a warning for the page, `page_render` and `render_preview` return a
JSON part after the PNG, `{ "page": 1, "warnings": [...] }`, with the sentences
`pdfcraft-cli render` prints: "content past the page's safety budget was skipped" and "a colour
that could not be computed was painted in a fallback colour". A page without warnings returns
`pdfcraft-cli render` prints, such as "content past the page's safety budget, or a picture whose
JBIG2 mask is past a decoder limit, was skipped" and "a colour that could not be computed was
painted in a fallback colour". A page without warnings returns
the PNG alone.
+1 -1
View File
@@ -9,7 +9,7 @@ use std::collections::BTreeSet;
use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic};
mod documents;
mod form_tools;
pub(crate) mod form_tools;
mod ocr;
fn status_id(s: Status) -> &'static str {
+40 -3
View File
@@ -1,7 +1,7 @@
use super::{Args, Automation, Result, ToolError, Value, failed, json, with_warnings, write_atomic};
/// A script's request to the viewer as the JSON that running a script reports; a side panel
/// goes by its fixed id.
/// A script's request to the viewer as the JSON that running a script reports (page numbers
/// 1-based); a side panel goes by its fixed id.
fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
use pdfcraft_engine::js::Request as R;
match r {
@@ -12,14 +12,16 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
R::Submit(u) => json!({ "submit": u }),
R::Focus(f) => json!({ "focus": f }),
R::Beep => json!({ "beep": true }),
R::Highlight(on) => json!({ "highlight": on }),
R::SaveAs => json!({ "save_as": true }),
R::OpenAttachment { name, open } => json!({ "attachment": name, "open": open }),
R::ShowPanel(p) => json!({ "panel": p.id() }),
}
}
/// A message a script showed, as `js_run` reports it: its text, title (null when it gave none),
/// icon and buttons.
fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
pub(crate) fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() })
}
@@ -47,6 +49,8 @@ impl Automation {
Ok(json!({ "scripts": list }))
}
/// `js_set_document_script`: set the document-level script `name` to `script`, or remove it
/// when `script` is left out, as one undoable edit; returns the document's scripts.
pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result<Value> {
let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) };
let id = self.doc(a)?.id;
@@ -54,6 +58,8 @@ impl Automation {
Ok(with_warnings(self.js_document_scripts(a)?, warnings))
}
/// `form_set_script`: set field `field`'s JavaScript for `event` to `script`, or remove it when
/// `script` is left out, as one undoable edit; returns the console output and errors.
pub(crate) fn form_set_script(&mut self, a: &Args) -> Result<Value> {
let edit = pdfcraft_engine::Edit::SetFieldScript {
name: a.str("field")?.into(),
@@ -65,6 +71,37 @@ impl Automation {
Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors }))
}
/// `form_focus`: field `field` (its 1-based `widget`) gains (`enter`) or loses (`exit`)
/// focus, running its XFA scripts for that event; returns whether the form changed and what
/// the scripts showed or asked for.
pub(crate) fn form_focus(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::js::FieldFocus;
let focus = match a.str("event")? {
"enter" => FieldFocus::Enter,
"exit" => FieldFocus::Exit,
other => return Err(ToolError::InvalidArgs(format!("event must be enter or exit, not {other:?}"))),
};
let name = a.str("field")?;
let doc = self.doc(a)?;
let field = doc.form.iter().find(|f| f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?;
// A field with no widget (one only scripts or calculations use) has nothing to focus.
let widgets = field.widgets.len();
let widget = a.opt_int("widget")?.unwrap_or(1);
let widget = usize::try_from(widget).ok().and_then(|w| w.checked_sub(1)).filter(|w| *w < widgets).ok_or_else(|| match widgets {
0 => ToolError::InvalidArgs(format!("{name:?} has no widget to focus")),
n => ToolError::InvalidArgs(format!("widget must be 1 to {n} for {name:?}")),
})?;
let id = doc.id;
let changed = self.session.field_focus(id, name, widget, focus).map_err(failed)?;
let out = self.session.take_js_output(id);
let reqs: Vec<Value> = out.requests.iter().map(request_json).collect();
Ok(
json!({ "changed": changed, "alerts": out.alerts.iter().map(alert_json).collect::<Vec<_>>(), "console": out.console, "requests": reqs, "errors": out.errors }),
)
}
/// `form_merge_data`: merge the form data files at `paths` into one CSV at `path`, a row per
/// file; returns the path and the row and column counts.
pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result<Value> {
let mut files = Vec::new();
for p in a.strs("paths")? {
+50 -17
View File
@@ -179,7 +179,21 @@ impl Automation {
let doc = self.doc(a)?;
let only = a.opt_int("page")?.map(|p| (p.max(1) - 1) as usize);
let all = self.comments(a)?;
// A group (`/RT /Group`, ISO 32000-2 §12.5.6.2) lists as its primary, with the primary's
// attributes: the subordinates are neither comments nor replies, only its `group`.
let mut members: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
for (m, p) in all.iter().zip(pdfcraft_render::group_primaries(&all)) {
if let Some(p) = p.and_then(|p| all.get(p)) {
members.entry((p.page, p.index)).or_default().push(m);
}
}
let view = |c: &Annotation| -> Value {
let group: Vec<Value> = members
.get(&(c.page, c.index))
.into_iter()
.flatten()
.map(|m| json!({ "id": m.name, "page": m.page + 1, "index": m.index + 1, "type": m.subtype }))
.collect();
let mut v = json!({
"id": c.name,
"page": c.page + 1,
@@ -191,6 +205,9 @@ impl Automation {
"modified": c.modified,
"rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))),
"color": c.color.map(hex),
"group": group,
// Unchanged on a page the viewer could not read, as `rect` is.
"popup": c.popup.map(|w| json!({ "rect": crate::view_rect(&doc.info, c.page, w.rect), "open": w.open })),
});
// A text box's or typed text's oval or circle (a rectangle is not reported).
if c.subtype == "FreeText"
@@ -211,8 +228,8 @@ impl Automation {
v["text_opacity"] = json!(t);
}
}
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
// comment_edit's `points` takes them.
// A line's ends, the corners of connected lines, a polygon or a cloud, or a callout's
// arrow point and knee, as comment_edit's `points` takes them.
// Unchanged on a page the viewer could not read, as `rect` is.
if !c.points.is_empty() {
let round = |v: f32| (v * 100.0).round() / 100.0;
@@ -220,15 +237,20 @@ impl Automation {
v["points"] =
json!(c.points.iter().map(|q| page.map_or([q[0], q[1]], |p| p.user_to_view(q[0], q[1])).map(round)).collect::<Vec<_>>());
}
// A callout's text box, as comment_edit's `rect` takes it (`rect` also holds the line).
if let Some(tb) = c.text_box {
v["text_box"] = json!(crate::view_rect(&doc.info, c.page, tb));
}
v
};
// Every reply under the comment its thread starts from, at any depth, in document order:
// a reply to a reply keeps its `/IRT` on the direct parent, so following one level would
// drop it. Parents are found by reference, as `/NM` need not be unique. Status and
// checkmark replies are read through `review` instead.
// checkmark replies are read through `review` instead. A group subordinate is in no
// thread, and a reply to one is the group's, its primary's.
let tops = pdfcraft_render::thread_tops(&all);
let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
for r in all.iter().filter(|r| r.state.is_none()) {
for r in all.iter().filter(|r| r.state.is_none() && !r.is_group_member()) {
let Some(&top) = tops.get(&(r.page, r.index)) else { continue };
threads.entry(top).or_default().push(r);
}
@@ -408,18 +430,8 @@ impl Automation {
let point = to_user(&info, t[0], t[1]);
let knee = match a.nums::<2>("knee")? {
Some(k) => to_user(&info, k[0], k[1]),
// Halfway between the point and the box, level with the box's middle.
None => {
let mid = (rect[1] + rect[3]) / 2.0;
let side = if point[0] < rect[0] {
rect[0]
} else if point[0] > rect[2] {
rect[2]
} else {
(rect[0] + rect[2]) / 2.0
};
[(point[0] + side) / 2.0, mid]
}
// Acrobat's: 12 pt out from the middle of the side facing the point.
None => pdfcraft_engine::callout_knee(rect, point),
};
Shape::Callout {
rect,
@@ -700,6 +712,17 @@ impl Automation {
self.apply(a, Edit::MarkAnnotation { page, index, marked, author })
}
pub(crate) fn comment_popup(&mut self, a: &Args) -> Result<Value> {
let (page, index) = self.comment_target(a)?;
let info = self.doc(a)?.info.pages[page].clone();
let open = a.opt_bool("open")?;
let rect = a.nums::<4>("rect")?.map(|r| rect_to_user(&info, r));
if open.is_none() && rect.is_none() {
return Err(ToolError::InvalidArgs("nothing to change: pass open and/or rect".into()));
}
self.apply(a, Edit::SetAnnotationPopup { page, index, rect, open })
}
pub(crate) fn comment_lock(&mut self, a: &Args) -> Result<Value> {
let (page, index) = self.comment_target(a)?;
let locked = a.opt_bool("locked")?.unwrap_or(true);
@@ -873,6 +896,7 @@ impl Automation {
let bytes = crate::read_input(&path)?;
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned()));
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
let edit = Edit::AddCustomStamp {
page,
rect,
@@ -882,7 +906,16 @@ impl Automation {
bytes: std::sync::Arc::new(bytes),
page: a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1,
},
author: a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(),
line: None,
author: author.clone(),
};
// A dynamic line (#1741): `{name}`, `{date}` and `{time}` filled in now.
let edit = match (edit, a.opt_str("line")?) {
(Edit::AddCustomStamp { page, rect, name, file, author, .. }, Some(t)) if !t.trim().is_empty() => {
let line = Some(self.session().stamp_line(t, &author));
Edit::AddCustomStamp { page, rect, name, file, author, line }
}
(edit, _) => edit,
};
self.apply(a, edit)
}
+46 -1
View File
@@ -700,7 +700,7 @@ impl Automation {
summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?)
}
"doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?,
"doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?,
"doc_header_footer" | "doc_watermark" | "doc_background" | "doc_printer_marks" | "doc_remove_marks" => self.marks(name, &a)?,
"doc_backdrops" => self.backdrops(&a)?,
"doc_unprotect" => {
let mut out = self.apply(&a, Edit::RemoveProtection)?;
@@ -709,6 +709,7 @@ impl Automation {
}
"form_fields" => self.form_fields(&a)?,
"form_fill" => self.form_fill(&a)?,
"form_focus" => self.form_focus(&a)?,
"form_set_image" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = read_input(&path)?;
@@ -768,6 +769,49 @@ impl Automation {
let edit = Edit::DeleteAttachment { key: a.str("name")?.into() };
self.apply(&a, edit)?
}
"doc_convert_colours" => self.convert_colours(&a)?,
"attachment_edit" => {
let key = a.opt_str("key")?;
let name = a.opt_str("name")?;
if key.is_some() == name.is_some() {
return Err(ToolError::InvalidArgs("provide exactly one of key or name".into()));
}
let keys: Vec<String> = self
.doc(&a)?
.info
.attachments
.iter()
.filter_map(|attachment| match &attachment.source {
pdfcraft_render::AttachmentSource::Document { key: attachment_key }
if key.is_some_and(|key| key == attachment_key) || name.is_some_and(|name| name == attachment.name) =>
{
Some(attachment_key.clone())
}
pdfcraft_render::AttachmentSource::Document { .. } => None,
pdfcraft_render::AttachmentSource::Annotation { .. } => None,
})
.collect();
let key = match keys.as_slice() {
[key] => key.clone(),
[] if key.is_some() => {
return Err(ToolError::InvalidArgs(format!("there is no document attachment with key {:?}", key.unwrap_or_default())));
}
[] => {
return Err(ToolError::InvalidArgs(format!(
"there is no document attachment named {:?}; use a name from doc_info",
name.unwrap_or_default()
)));
}
_ => {
return Err(ToolError::InvalidArgs(format!(
"more than one document attachment is named {:?}; provide its key from doc_info",
name.unwrap_or_default()
)));
}
};
let edit = Edit::EditAttachmentDescription { key, description: Some(a.str("description")?.into()) };
self.apply(&a, edit)?
}
"doc_remove_hidden" => self.doc_remove_hidden(&a)?,
"doc_audit_redactions" => self.doc_audit_redactions(&a)?,
"fill_sign_add" => self.fill_sign_add(&a)?,
@@ -874,6 +918,7 @@ impl Automation {
"sign_trust" => self.sign_trust(&a)?,
"comment_mark" => self.comment_mark(&a)?,
"comment_lock" => self.comment_lock(&a)?,
"comment_popup" => self.comment_popup(&a)?,
"comments_hide" => self.comments_hide(&a)?,
"layer_set" => self.layer_set(&a)?,
"view_reading_colors" => self.view_reading_colors(&a)?,
+44 -2
View File
@@ -104,12 +104,27 @@ impl Automation {
}
pub(crate) fn doc_open(&mut self, a: &Args) -> Result<Value> {
let path = self.resolve(a.str("path")?, false)?;
let requested = a.str("path")?;
// `manual.pdf#nameddest=Intro`, as a help system starts a PDF reader (#3425): the file under
// --root, and where it opens. A path that names a file is never split.
let parameters = pdfcraft_engine::open_parameters::split(requested, |p| self.resolve(p, false).is_ok_and(|p| p.is_file()));
let (requested, destination) = match &parameters {
Some((file, destination)) => (file.as_str(), Some(destination.clone())),
None => (requested, None),
};
let path = self.resolve(requested, false)?;
let bytes = read_input(&path)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?;
Ok(summary(doc))
let mut value = summary(doc);
if let Some(destination) = destination {
let (applied, destination, warning) = crate::links::destination_report(doc, destination);
value["destination_applied"] = json!(applied);
value["destination"] = destination;
value["warning"] = json!(warning);
}
Ok(value)
}
pub(crate) fn doc_close(&mut self, a: &Args) -> Result<Value> {
@@ -360,6 +375,7 @@ impl Automation {
("discard_thumbnails", &mut settings.discard_thumbnails),
("discard_alternate_images", &mut settings.discard_alternate_images),
("discard_tags", &mut settings.discard_tags),
("discard_search_index", &mut settings.discard_search_index),
("discard_print_settings", &mut settings.discard_print_settings),
("discard_form_actions", &mut settings.discard_form_actions),
("flate_unencoded", &mut settings.flate_unencoded),
@@ -367,6 +383,7 @@ impl Automation {
("lzw_to_flate", &mut settings.lzw_to_flate),
("remove_invalid_links", &mut settings.remove_invalid_links),
("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests),
("discard_external_refs", &mut settings.discard_external_refs),
] {
if let Some(b) = a.opt_bool(key)? {
*flag = b;
@@ -471,6 +488,7 @@ impl Automation {
"image_bytes_after": o.image_bytes_after,
"thumbnails": o.thumbnails,
"alternate_images": o.alternate_images,
"search_indexes": o.search_indexes,
"tags_removed": o.tags_removed,
"print_settings": o.print_settings,
"form_actions": o.form_actions,
@@ -479,6 +497,8 @@ impl Automation {
"invalid_links": o.invalid_links,
"invalid_bookmarks": o.invalid_bookmarks,
"unreferenced_dests": o.unreferenced_dests,
"external_links": o.external_links,
"external_bookmarks": o.external_bookmarks,
"unused_xobjects": o.unused_xobjects,
"merged_objects": r.merged,
"discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::<Vec<_>>(),
@@ -626,11 +646,33 @@ impl Automation {
replace,
file: self.mark_file(a)?,
},
"doc_printer_marks" => {
use pdfcraft_engine::PrinterMarks;
let flag = |k: &str, default: bool| -> Result<bool> { Ok(a.opt_bool(k)?.unwrap_or(default)) };
let (y, m, d) = self.session.today();
let settings = PrinterMarks {
trim: flag("trim", true)?,
bleed: flag("bleed", true)?,
registration: flag("registration", true)?,
color_bars: flag("color_bars", true)?,
page_info: flag("page_info", true)?,
line_weight: a.opt_num("line_weight")?.unwrap_or(0.25),
make_room: match a.opt_num("make_room")? {
Some(r) if r < 0.0 => None,
Some(r) => Some(r),
None => Some(36.0),
},
label: a.opt_str("label")?.map(str::to_string).unwrap_or_else(|| self.doc(a).map(|d| d.name.clone()).unwrap_or_default()),
date: a.opt_str("date")?.map(str::to_string).unwrap_or_else(|| format!("{y:04}-{m:02}-{d:02}")),
};
Edit::AddPrinterMarks { pages, settings, replace: a.opt_bool("replace")?.unwrap_or(true) }
}
_ => {
let kind = match a.str("kind")? {
"header_footer" => MarkKind::HeaderFooter,
"watermark" => MarkKind::Watermark,
"background" => MarkKind::Background,
"printer_marks" => MarkKind::PrinterMarks,
other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))),
};
Edit::RemoveMarks { kind }
+37 -2
View File
@@ -176,7 +176,7 @@ fn calculate_json(f: &FormField) -> Option<Value> {
match (&f.actions.calculate, &f.actions.scripts.calculate) {
(Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })),
(Calculate::Notation(n), _) => Some(json!({ "notation": n })),
(Calculate::None, Some(js)) => Some(json!({ "script": js })),
(Calculate::Custom(js), _) | (Calculate::None, Some(js)) => Some(json!({ "script": js })),
(Calculate::None, None) => None,
}
}
@@ -208,6 +208,24 @@ fn kind_name(k: FormFieldKind) -> &'static str {
}
}
/// A JSON number as the radio option it names: its integer spelling when it is a whole number
/// (`1`, `1.0` and `1e0` are all `1`; `-0` is `0`), otherwise JSON's own spelling (`1.5`), which
/// names only an option exported as exactly that text. A whole number at or past 2^53, where a
/// float no longer holds every integer, keeps JSON's spelling too (`1e300`), so it names no option
/// by accident.
fn number_option(n: &serde_json::Number) -> String {
// Integers as parsed (u64, i64) print exactly.
if n.is_u64() || n.is_i64() {
return n.to_string();
}
const EXACT: f64 = 9_007_199_254_740_992.0; // 2^53
match n.as_f64() {
// Finite, whole and below 2^53 in magnitude, so the cast is exact and in range.
Some(x) if x.is_finite() && x.fract() == 0.0 && x.abs() < EXACT => (x as i64).to_string(),
_ => n.to_string(),
}
}
/// A JSON value → the value for this field (booleans for check boxes, arrays for lists…).
fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
let wrong = |what: &str| ToolError::InvalidArgs(format!("{}: {what}", f.name));
@@ -224,7 +242,24 @@ fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
FieldValue::Widget(i)
}
(FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None),
(FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(s.clone())),
// An option as `form_fields` lists it (an export value) names its button's on state. The
// export values are matched first and the on-state names after them
// (`Field::state_for_export`): with `/Opt [(1) (2)]` and states `/0` `/1`, `1` is the first
// button, not the second one's state. A value matching neither is refused when written,
// with the options listed.
(FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(f.state_for_export(s).unwrap_or(s).to_owned())),
// A number is the same option written without quotes (`1` for the export value `1`).
// Below 2^53 it is compared as a number with the export values first, so `1.0` names the button
// exported as `1.0` (`/Opt [(1.0) (2.0)]`, states `/0 /1`) and not the one whose state is
// `/1`; then as text, as a string would be.
(FormFieldKind::Radio, Value::Number(n)) => {
if let Some(state) = n.as_f64().and_then(|x| f.state_for_number(x)) {
FieldValue::Radio(Some(state.to_owned()))
} else {
let s = number_option(n);
FieldValue::Radio(Some(f.state_for_export(&s).map_or_else(|| s.clone(), str::to_owned)))
}
}
(FormFieldKind::Combo | FormFieldKind::List, Value::Array(a)) => FieldValue::Choice(
a.iter().map(|x| x.as_str().map(str::to_owned).ok_or_else(|| wrong("list values must be strings"))).collect::<Result<_>>()?,
),
+6 -1
View File
@@ -347,7 +347,12 @@ pub(crate) fn info(d: &Document) -> Value {
"horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque,
"display": t.display,
})).collect::<Vec<_>>(),
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "key": match &a.source { pdfcraft_render::AttachmentSource::Document { key } => Some(key), pdfcraft_render::AttachmentSource::Annotation { .. } => None }, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
// A PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): its pages are a cover sheet and its
// documents are `attachments`; `initial` names the one presented first, null for the cover.
"portfolio": i.portfolio.as_ref().map(|p| json!({
"initial": p.initial.and_then(|n| i.attachments.get(n)).map(|a| &a.name), "view": p.view.name(),
})),
// `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
+21 -14
View File
@@ -11,6 +11,26 @@ use std::path::Path;
use crate::comments::parse_color;
use crate::{Args, Automation, Result, ToolError, failed};
/// Where `destination` puts `doc`, a document just opened from a link or with open parameters:
/// whether it applies, the destination as reported, and a warning when it doesn't. A named
/// destination is looked up in the document, as the app does.
pub(crate) fn destination_report(doc: &pdfcraft_engine::Document, destination: RemoteDestination) -> (bool, Value, Option<&'static str>) {
let (name, page, view) = match destination {
RemoteDestination::Default => return (true, json!({ "kind": "default" }), None),
RemoteDestination::Page { page, view } => (None, page, view),
RemoteDestination::Named(name) => match pdfcraft_render::named_destination(doc.bytes.clone(), doc.password.as_deref(), &name) {
Some((page, view)) => (Some(name), page, view),
None => return (false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied.")),
},
};
let mut value = json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) });
if let Some(name) = name {
value["kind"] = json!("named");
value["name"] = json!(name);
}
if page < doc.info.pages.len() { (true, value, None) } else { (false, value, Some("The PDF opened, but the page destination is out of range.")) }
}
fn bad(m: impl Into<String>) -> ToolError {
ToolError::InvalidArgs(m.into())
}
@@ -222,20 +242,7 @@ impl Automation {
let id =
self.session.open(name, Some(path.to_string_lossy().into_owned()), std::sync::Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
let opened = self.session.get(id).ok_or_else(|| failed("the linked document vanished"))?;
let (destination_applied, destination_value, warning) = match destination {
RemoteDestination::Default => (true, json!({ "kind": "default" }), None),
RemoteDestination::Named(name) => {
(false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied."))
}
RemoteDestination::Page { page, view } if page < opened.info.pages.len() => {
(true, json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }), None)
}
RemoteDestination::Page { page, view } => (
false,
json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }),
Some("The PDF opened, but the link's page destination is out of range."),
),
};
let (destination_applied, destination_value, warning) = destination_report(opened, destination);
let mut display_path = path.to_string_lossy().into_owned();
if let Some(root) = &self.root
&& let Ok(relative) = path.strip_prefix(root)
+3 -1
View File
@@ -412,7 +412,9 @@ pub(crate) fn rendered_content(out: &pdfcraft_render::RenderedPage, page: usize)
fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str {
use pdfcraft_render::RenderWarning;
match warning {
RenderWarning::ContentTruncated => "content past the page's safety budget was skipped",
RenderWarning::ContentTruncated => {
"content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped"
}
RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour",
RenderWarning::MalformedIndexedImage => "an image with a malformed Indexed colour table may be drawn wrongly or not at all",
RenderWarning::Type3WidthsMissing => "a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give",
+103 -23
View File
@@ -1,7 +1,7 @@
//! Printing tools: list printers, and print (or save the print-ready PDF) with Acrobat's Print
//! dialog options.
use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool};
use pdfcraft_engine::print::{self, Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool};
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError, failed, write_atomic};
@@ -94,8 +94,19 @@ fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<((f64, f64
/// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on
/// (as `media` after the driver's `options`) rather than the queue's default paper, which a job
/// naming none got (A4 under Letter sheets on the queue in #3752).
fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duplex>, media: Option<&str>) -> Result<spool::Job> {
/// naming none got (A4 under Letter sheets on the queue in #3752), and tells CUPS which way the
/// laid-out `sheets` run, as the Print dialog does (#2713): an orientation the caller chose in
/// `options` stands. It asks the driver for grayscale only when the pages aren't grey pictures
/// already ([`print::driver_needs_grayscale`]).
fn spool_job(
a: &Args,
printer: &str,
title: String,
duplex: Option<spool::Duplex>,
media: Option<&str>,
raster: Option<print::Raster>,
sheets: &[print::Sheet],
) -> Result<spool::Job> {
let options = match a.get("options") {
None => Vec::new(),
Some(Value::Object(m)) => m
@@ -113,13 +124,56 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duple
collate: a.opt_bool("collate")?.unwrap_or(true),
duplex,
scaling: spool::Scaling::Exact,
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
grayscale: print::driver_needs_grayscale(a.opt_bool("grayscale")?.unwrap_or(false), raster),
title,
options: options.into_iter().chain(media.map(|m| ("media".to_string(), m.to_string()))).collect(),
options: {
let mut options: Vec<(String, String)> = options.into_iter().chain(media.map(|m| ("media".to_string(), m.to_string()))).collect();
print::add_cups_orientation(&mut options, sheets);
options
},
})
}
impl Automation {
/// Print production ▸ Convert colours: every colour on the pages in gray, rgb or cmyk.
pub(crate) fn convert_colours(&mut self, a: &Args) -> Result<Value> {
let n = self.doc(a)?.info.pages.len();
let pages = match a.opt_ints("pages")? {
Some(_) => self.pages(a, "pages")?,
None => (0..n).collect(),
};
let target = match a.opt_str("to")?.unwrap_or("gray") {
"gray" | "grayscale" => pdfcraft_engine::ColourTarget::Gray,
"rgb" => pdfcraft_engine::ColourTarget::Rgb,
"cmyk" => pdfcraft_engine::ColourTarget::Cmyk,
other => return Err(ToolError::InvalidArgs(format!("unknown target {other:?} (gray, rgb, cmyk)"))),
};
let settings = pdfcraft_engine::ConvertColours {
target,
preserve_black: a.opt_bool("preserve_black")?.unwrap_or(true),
images: a.opt_bool("images")?.unwrap_or(true),
annotations: a.opt_bool("annotations")?.unwrap_or(false),
};
let id = self.doc(a)?.id;
let found = self.session.count_colours(id, &pages, &settings);
if found.colours == 0 && found.images == 0 {
return Ok(json!({ "colours": 0, "images": 0, "images_left": found.images_left, "pages": 0, "loses_conformance": [] }));
}
// PDF/X has no declaration of its own here: it always carries an output intent.
let declared = self.doc(a)?.standards();
let pdfa = declared.pdfa.map(|(part, level)| format!("PDF/A-{part}{}", level.to_lowercase()));
let loses: Vec<String> = pdfa.into_iter().chain(declared.output_intents.iter().map(|i| format!("output intent {i}"))).collect();
let r = self.apply(a, pdfcraft_engine::Edit::ConvertColours { pages, settings })?;
Ok(json!({
"colours": found.colours,
"images": found.images,
"images_left": found.images_left,
"pages": found.pages,
"loses_conformance": loses,
"undo": r["undo"],
}))
}
/// `printers`: the system's printers, the default marked, and each one's two-sided support
/// where the spooler reports it.
pub(crate) fn printers(&self) -> Result<Value> {
@@ -178,9 +232,6 @@ impl Automation {
let pages = print::select_pages(count, a.opt_str("pages")?, &labels, subset, a.opt_bool("reverse")?.unwrap_or(false))
.map_err(|e| bad(e.to_string()))?;
let layout = match a.opt_str("layout")?.unwrap_or("fit") {
"fit" => Layout::Size(SizeMode::Fit),
"actual" => Layout::Size(SizeMode::Actual),
"shrink" => Layout::Size(SizeMode::Shrink),
"custom" => Layout::Size(SizeMode::Custom(a.opt_num("scale")?.ok_or_else(|| bad("custom needs scale (percent)"))?)),
"multiple" => {
// Custom pages per sheet: `cols` across by `rows` down, both or neither.
@@ -225,13 +276,14 @@ impl Automation {
s => return Err(bad(format!("unknown booklet_subset {s:?}"))),
},
binding: if a.opt_str("binding")? == Some("right") { Binding::Right } else { Binding::Left },
creep: a.opt_num("creep")?.unwrap_or(0.0),
},
"poster" => Layout::Poster {
scale: a.opt_num("scale")?.unwrap_or(200.0),
overlap: a.opt_num("overlap")?.unwrap_or(18.0),
cut_marks: a.opt_bool("cut_marks")?.unwrap_or(true),
},
l => return Err(bad(format!("unknown layout {l:?}"))),
l => Layout::Size(print::names::parse_size_mode(l, 100.0).ok_or_else(|| bad(format!("unknown layout {l:?}")))?),
};
let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?;
if (a.opt_int("cols")?.is_some() || a.opt_int("rows")?.is_some()) && !matches!(layout, Layout::Multiple { .. }) {
@@ -246,13 +298,8 @@ impl Automation {
"landscape" => Orientation::Landscape,
o => return Err(bad(format!("unknown orientation {o:?}"))),
};
let content = match a.opt_str("comments_forms")?.unwrap_or("document-and-markups") {
"document" => Content::Document,
"document-and-markups" => Content::DocumentAndMarkups,
"document-and-stamps" => Content::DocumentAndStamps,
"form-fields-only" => Content::FormFieldsOnly,
c => return Err(bad(format!("unknown comments_forms {c:?}"))),
};
let content = a.opt_str("comments_forms")?.unwrap_or("document-and-markups");
let content = print::names::parse_content(content).ok_or_else(|| bad(format!("unknown comments_forms {content:?}")))?;
let printer_arg = a.opt_str("printer")?;
let resolved_printer = printer_arg.map(|p| {
if p == "default" {
@@ -275,7 +322,8 @@ impl Automation {
};
let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new() };
let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect();
let sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len();
let laid_out = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?;
let sheets = laid_out.len();
let bytes = self.session.print_pdf(id, &settings).map_err(failed)?;
let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() });
match (a.opt_str("path")?, a.opt_str("printer")?) {
@@ -286,7 +334,7 @@ impl Automation {
}
(None, Some(printer)) => {
let actual_printer = resolved_printer.as_deref().unwrap_or(printer);
let job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, Some(&media))?;
let job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, Some(&media), settings.raster, &laid_out)?;
out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?);
}
_ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")),
@@ -302,7 +350,7 @@ mod tests {
/// #2707: a booklet sets two-sided itself whatever `duplex` asks; a bad name is still an error.
#[test]
fn a_booklet_decides_two_sided_itself() {
let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left };
let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left, creep: 0.0 };
for asked in ["printer", "off", "long-edge", "short-edge"] {
assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}");
assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}");
@@ -325,13 +373,31 @@ mod tests {
assert!(duplex("sideways", false).is_err());
}
/// #2713, #3739: `doc_print` to a printer tells CUPS when every sheet runs landscape, after
/// the paper, so a landscape sheet is turned onto the portrait paper rather than printed
/// unrotated and cut off. Portrait and mixed jobs say nothing, and an orientation passed in
/// `options` stands and isn't sent twice.
#[test]
fn printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them() {
let sheets = |sizes: &[(f64, f64)]| sizes.iter().map(|&size| print::Sheet { size, ..Default::default() }).collect::<Vec<_>>();
let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, Some("Letter"), None, s).unwrap().options;
let media = ("media".to_string(), "Letter".to_string());
let landscape = sheets(&[(792.0, 612.0), (792.0, 612.0)]);
let turned = if cfg!(windows) { vec![media.clone()] } else { vec![media.clone(), ("orientation-requested".to_string(), "4".to_string())] };
assert_eq!(job(json!({}), &landscape), turned);
assert_eq!(job(json!({}), &sheets(&[(612.0, 792.0)])), vec![media.clone()], "portrait sheets");
assert_eq!(job(json!({}), &sheets(&[(792.0, 612.0), (612.0, 792.0)])), vec![media.clone()], "a job that mixes them");
let chosen = ("orientation-requested".to_string(), "5".to_string());
assert_eq!(job(json!({ "options": { "orientation-requested": "5" } }), &landscape), [chosen, media], "the caller's own choice, once");
}
/// #3752: `doc_print` to a printer sent no paper, so the queue's default paper stood in for
/// the one the sheets were laid out on (A4 under Letter sheets on the report's queue).
#[test]
fn printing_to_a_printer_feeds_the_laid_out_paper() {
let job = |args: Value, paper_name: Option<&str>| {
let (_, media) = resolve_paper(paper_name, None).unwrap();
spool_job(&Args(&args), "Office", "a.pdf".into(), None, Some(&media)).unwrap()
spool_job(&Args(&args), "Office", "a.pdf".into(), None, Some(&media), None, &[]).unwrap()
};
let media = |m: &str| ("media".to_string(), m.to_string());
// US Letter when no paper is asked for, after the driver's own options.
@@ -346,7 +412,6 @@ mod tests {
// Short forms find the same paper.
assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]);
assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]);
// Non-preset dimensions and driver papers carry their media selection.
assert_eq!(job(json!({}), Some("300x500")).options, [media("Custom.300x500")]);
assert_eq!(job(json!({}), Some("300 x 500 pt")).options, [media("Custom.300x500")]);
@@ -356,7 +421,22 @@ mod tests {
assert_eq!(job(json!({}), Some("Custom.300x500pt")).options, [media("Custom.300x500")]);
assert!(matches!(resolve_paper(Some("Quarto"), None), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter")));
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, Some("Letter")).ok().map(|j| j.printer), Some(None));
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, Some("Letter")).is_err());
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, Some("Letter"), None, &[]).ok().map(|j| j.printer), Some(None));
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, Some("Letter"), None, &[]).is_err());
}
/// #5806: grayscale with Print as image sends grey pictures, so the driver isn't asked for
/// grayscale, which Microsoft Print to PDF keeps no setting for and the job was refused.
#[test]
fn grey_pictures_dont_ask_the_driver_for_grayscale() {
let grey = print::Raster { dpi: 300.0, grayscale: true };
let colour = print::Raster { dpi: 300.0, grayscale: false };
let job = |args: Value, raster: Option<print::Raster>| {
spool_job(&Args(&args), "Office", String::new(), None, Some("Letter"), raster, &[]).unwrap().grayscale
};
assert!(job(json!({ "grayscale": true }), None), "vector pages: the driver makes them grey");
assert!(!job(json!({ "grayscale": true }), Some(grey)), "grey pictures: nothing for the driver to do");
assert!(job(json!({ "grayscale": true }), Some(colour)), "colour pictures still need the driver");
assert!(!job(json!({}), None));
}
}
+18
View File
@@ -200,6 +200,24 @@ impl Automation {
let after = self.doc(a)?.redaction_marks();
out["applied"] = json!(before - after);
out["marks_pending"] = json!(after);
// Where the removed text is still held that applying leaves as it is.
let left = self.doc(a)?.redaction_elsewhere().unwrap_or_default();
out["still_holding"] = json!({
"bookmarks": left.bookmarks,
"attachments": left.attachments,
"comments": left.comments,
"xmp": left.xmp,
"total": left.total(),
"unsearched": left.unsearched,
});
// What became of a PDF/A or PDF/UA claim in the document XMP the scrub removed.
let claim = |c: pdfcraft_engine::RedactionClaim| match c {
pdfcraft_engine::RedactionClaim::None => "none",
pdfcraft_engine::RedactionClaim::Kept => "kept",
pdfcraft_engine::RedactionClaim::Lost => "lost",
};
let id = self.doc(a)?.redaction_identification().unwrap_or_default();
out["identification"] = json!({ "pdfa": claim(id.pdfa), "pdfua": claim(id.pdfua) });
// The name to save the redacted copy under, as Save does in the app.
out["save_as"] = json!(redacted_file_name(&self.doc(a)?.name));
Ok(out)
+84 -15
View File
@@ -2,6 +2,7 @@
//! document, and manage trusted certificates. Rectangles are points from the top-left of the
//! displayed page.
use pdfcraft_engine::js::SignEvent;
use pdfcraft_engine::sign::{self, Certificate, DigitalId, Modification, Name, PrivateKey};
use pdfcraft_engine::{SignOptions, SignatureInfo, SignatureStatus};
use serde_json::{Value, json};
@@ -175,6 +176,8 @@ impl Automation {
Ok(json!({ "path": path.to_string_lossy(), "certificate": cert_json(&id.certificate) }))
}
/// The digital ID `id` names: a .p12/.pfx file opened with `password`, or an OS key store
/// identity (`keychain:` on macOS, `windows:` on Windows).
fn open_id(&self, a: &Args) -> Result<DigitalId> {
// A macOS Keychain identity: "keychain:<fingerprint>" or "keychain:<common name>".
if let Some(r) = a.str("id")?.strip_prefix("keychain:") {
@@ -197,6 +200,8 @@ impl Automation {
})
}
/// `sign_document`: sign the document with a digital ID into `out`, visibly (in a field or a
/// rectangle) or not, running a dynamic form's signing scripts around it.
pub(crate) fn sign_document(&mut self, a: &Args) -> Result<Value> {
let id = self.open_id(a)?;
let doc = self.doc(a)?;
@@ -246,30 +251,94 @@ impl Automation {
Some(v) => labels_arg(v)?,
};
let out = self.resolve(a.str("out")?, true)?;
let opts = SignOptions {
field,
page,
rect,
reason: a.opt_str("reason")?.map(str::to_string),
location: a.opt_str("location")?.map(str::to_string),
contact: a.opt_str("contact")?.map(str::to_string),
certify,
appearance,
appearance_labels,
graphic,
..SignOptions::default()
// A dynamic form's signing scripts run only when asked: what preSign changes is signed,
// and the caller hasn't seen it. What they changed is listed.
let run_scripts = a.opt_bool("run_form_scripts")?.unwrap_or(false);
// Every argument is read before the preSign scripts change anything, so a bad one is
// refused with the document as it was.
let reason = a.opt_str("reason")?.map(str::to_string);
let location = a.opt_str("location")?.map(str::to_string);
let contact = a.opt_str("contact")?.map(str::to_string);
let signing = field.clone();
let scripted = signing.as_deref().is_some_and(|f| {
self.session.sign_event_scripted(doc_id, f, SignEvent::Before) || self.session.sign_event_scripted(doc_id, f, SignEvent::After)
});
// Each field's value and whether it is read-only, to tell what the preSign scripts changed.
let values = |me: &Automation| -> std::collections::BTreeMap<String, (Vec<String>, bool)> {
me.session.get(doc_id).map(|d| d.form.iter().map(|f| (f.name.clone(), (f.value.clone(), f.read_only()))).collect()).unwrap_or_default()
};
let was = values(self);
let before = match &signing {
Some(f) if run_scripts => self.session.run_sign_event(doc_id, f, SignEvent::Before).map_err(failed)?,
_ => false,
};
let changed: Vec<Value> = if before {
values(self)
.into_iter()
.filter(|(name, now)| was.get(name) != Some(now))
.map(|(name, (value, read_only))| {
let (old, old_ro) = was.get(&name).cloned().unwrap_or_default();
json!({ "field": name, "before": old, "after": value, "read_only_before": old_ro, "read_only": read_only })
})
.collect()
} else {
Vec::new()
};
let opts =
SignOptions { field, page, rect, reason, location, contact, certify, appearance, appearance_labels, graphic, ..SignOptions::default() };
let signed = self.session.sign(doc_id, &id, opts).map_err(|e| match e {
// A graphic the signer refuses is the caller's to fix.
pdfcraft_engine::EditError::Sign(m) if m.starts_with("the signature graphic") => bad(m),
e => failed(e),
})?;
write_atomic(&out, &signed)?;
});
let signed = match signed {
Ok(s) => s,
Err(e) => {
// Not signed: what the preSign scripts did is taken back.
if before {
let _ = self.session.undo(doc_id);
let _ = self.session.take_js_output(doc_id);
}
return Err(e);
}
};
if let Err(e) = write_atomic(&out, &signed) {
// Not saved, so not signed: what the preSign scripts did is taken back too.
if before {
let _ = self.session.undo(doc_id);
let _ = self.session.take_js_output(doc_id);
}
return Err(e);
}
let path = out.to_string_lossy().into_owned();
self.session.mark_signed(doc_id, signed.clone(), Some(path.clone())).map_err(failed)?;
// Then its postSign scripts; what they change is a change after signing, not saved yet.
// The file is signed and saved by now: a postSign step that fails is reported, not an error.
let (after, after_error) = match &signing {
Some(f) if run_scripts => match self.session.run_sign_event(doc_id, f, SignEvent::After) {
Ok(ran) => (ran, None),
Err(e) => (true, Some(format!("the postSign step couldn't finish: {e}"))),
},
_ => (false, None),
};
let mut scripts = self.session.take_js_output(doc_id);
scripts.errors.extend(after_error);
let doc = self.doc(a)?;
let newest = doc.signatures.iter().filter(|s| s.signed).max_by_key(|s| s.revision).cloned();
Ok(json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? }))
let mut result = json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? });
if before || after {
result["form_scripts"] = json!({
"before_signing": before,
"after_signing": after,
"changed": changed,
"alerts": scripts.alerts.iter().map(crate::a11y::form_tools::alert_json).collect::<Vec<_>>(),
"errors": scripts.errors,
"unsaved_changes": self.doc(a)?.dirty,
});
} else if scripted {
result["form_scripts"] = json!({ "skipped": true, "why": "the field has signing scripts; pass run_form_scripts: true to run them" });
}
Ok(result)
}
/// `{"image": path}` (PNG or JPEG), `{"text": name}` (typed in the script font) or
+69 -19
View File
@@ -171,11 +171,23 @@ pub fn tools() -> Vec<ToolDef> {
t("attachment_delete", "Delete an attachment", "Remove the document-level attachment with this name (as doc_info lists it). Undoable.")
.destructive()
.with(schema(json!({"doc": doc(), "name": {"type": "string"}}), &["doc", "name"])),
t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.")
t("doc_convert_colours", "Convert colours", "Print production: every colour operator in the pages' content and the artwork it draws rewritten in the target space (to: gray, rgb or cmyk) by the standard device formulas (device spaces, ICC by component count, Lab, Indexed, Separation and DeviceN tints; patterns, shadings and inline images stay). Not a prepress separation: no ICC profile and no output intent is used. Spot colours are converted, not kept: exact through a Type 2 tint, otherwise a gray of their darkness; /None stays. preserve_black (default true) keeps pure black as 100% K / gray 0; images (default true) re-encodes 8-bit gray and RGB pictures (JPEG stays JPEG, compressed again at quality 80; CMYK written lossless; soft masks kept), and leaves indexed, CMYK, masked and other pictures as they are; annotations also converts the pages' comment and field appearances. Returns how many colours and pictures changed on how many pages, images_left (pictures not in the target that stayed), and loses_conformance (the PDF/A level and output intents the file declared: it may no longer meet them, and still declares them; standards_remove_id takes that off). Undoable.")
.cmd("prepress.convert_colors")
.with(schema(
json!({
"doc": doc(), "pages": pages("to convert (default all)"),
"to": { "type": "string", "enum": ["gray", "rgb", "cmyk"] },
"preserve_black": { "type": "boolean" }, "images": { "type": "boolean" }, "annotations": { "type": "boolean" },
}),
&["doc"],
)),
t("attachment_edit", "Edit an attachment description", "Change the description of a document attachment by its exact key from doc_info, or by a unique displayed name. An empty description clears it. The file and other attachment data are preserved. Undoable.")
.with(schema(json!({"doc": doc(), "key": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "description"])),
t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited. A path that ends in open parameters, as help systems pass them (`manual.pdf#page=12`, `#nameddest=Intro` or `#Intro`), opens the file and reports its destination as link_open does; a path that names a file opens as it is.")
.cmd("file.open")
.with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])),
t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])),
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers and the Layers panel's tree (layer_order), page transitions and display times, the destination it opens at, attachments, fonts, security, repair notes and the objects that could not be read.")
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers and the Layers panel's tree (layer_order), page transitions and display times, the destination it opens at, attachments (and, for a PDF portfolio, the one it presents first), fonts, security, repair notes and the objects that could not be read.")
.ro()
.cmd("file.properties")
.with(schema(json!({ "doc": doc() }), &["doc"])),
@@ -200,7 +212,7 @@ pub fn tools() -> Vec<ToolDef> {
)),
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); 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, and a custom property is kept there as pdfx:<name> when its name can be an XML name (in a PDF/A file only one already there is changed). 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). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.")
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a picture left out because its JBIG2 mask is past a decoder limit, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 },
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." },
@@ -514,9 +526,23 @@ pub fn tools() -> Vec<ToolDef> {
t(
"form_fill",
"Fill in form fields",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes (or one of a check box's options, to check that widget and clear the others), an array of strings for multi-select lists; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes (or one of a check box's options, to check that widget and clear the others), an array of strings for multi-select lists; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. A radio option is an export value as form_fields lists it in options; where one button's on-state name equals another button's export value, the export value wins, and an on-state name that no export value matches still selects its button. A radio option may also be a JSON number: a whole number names the option spelled as that integer (1, 1.0 and 1e0 all name \"1\"), any other number only an option spelled exactly as JSON prints it (1.5). A value that names no option is refused with the options listed. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
)
.with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])),
t(
"form_focus",
"Move the focus into or out of a field",
"A field gains (event enter) or loses (event exit) focus, as when someone clicks or tabs into or out of it, and the dynamic XFA form's enter or exit scripts for it run (other forms have none, and nothing happens). widget picks the radio button or copy of the field (1-based, default 1; see form_fields). What the scripts change is one undoable edit, and none when they change nothing. Changing a field's value (form_fill) runs its exit scripts already, so the next exit of that field doesn't run them again. Returns changed, and the scripts' alerts, console output, requests and errors.",
)
.with(schema(
json!({
"doc": doc(),
"field": { "type": "string", "description": "Field name (from form_fields)." },
"event": { "type": "string", "enum": ["enter", "exit"] },
"widget": { "type": "integer", "minimum": 1, "description": "Widget of the field (1-based, default 1): a radio button of the group, or a copy of the field." },
}),
&["doc", "field", "event"],
)),
t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "fields": { "type": "array", "items": { "type": "string" } } }), &["doc"])),
@@ -563,7 +589,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "page", "rect", "rows"],
)),
t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). Undoable.")
t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). line adds a text line under the picture with {name} (the author), {date} and {time} filled in now, as a dynamic stamp. Undoable.")
.cmd("comment.stamp")
.with(schema(
json!({
@@ -575,6 +601,7 @@ pub fn tools() -> Vec<ToolDef> {
"file_page": { "type": "integer", "minimum": 1 },
"name": { "type": "string" },
"author": { "type": "string" },
"line": { "type": "string", "maxLength": 200 },
}),
&["doc", "page", "path"],
)),
@@ -597,7 +624,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"form_set_props",
"Field properties",
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise; the field keeps its rectangle and its content turns inside it. Undoable.",
)
.with(schema(
json!({
@@ -621,7 +648,7 @@ pub fn tools() -> Vec<ToolDef> {
"flags": { "type": "object", "additionalProperties": { "type": "boolean" } },
"font_size": { "type": "number", "minimum": 0 },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Move/resize: points from the top-left of the displayed page." },
"rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." },
"rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. The field keeps its rectangle; its content turns inside it." },
"appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." },
"format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." },
"validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out, or null) or \"none\"." },
@@ -678,7 +705,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"redact_apply",
"Apply redactions",
"Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Undoable until saved; the saved file no longer contains the content. The result's save_as is the document's name with _Redacted before the extension (contract.pdf gives contract_Redacted.pdf; a name that already ends in _Redacted is unchanged); pass it to doc_save as path to save under that name.",
"Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Document metadata (Info entries, the document's and pages' XMP) that repeats the removed text is removed too; when the document XMP goes, identification says what became of its PDF/A and PDF/UA claims (pdfa, pdfua: none, kept when written back in a packet holding only the identification, or lost). still_holding counts the places applying leaves as they are that still hold the removed text: bookmarks (titles), attachments (names, file names, descriptions), comments (text, rich text, subject or author of those that remain) and xmp (XMP on images, fonts, forms, annotations and other objects), with their total; doc_remove_hidden removes them. unsearched: true means the removed text couldn't be searched for, so every such place holding any text is counted. Undoable until saved; the saved file no longer contains the content. The result's save_as is the document's name with _Redacted before the extension (contract.pdf gives contract_Redacted.pdf; a name that already ends in _Redacted is unchanged); pass it to doc_save as path to save under that name.",
)
.destructive()
.with(schema(json!({ "doc": doc(), "pages": pages("whose marks to apply (default: all)") }), &["doc"])),
@@ -744,6 +771,7 @@ pub fn tools() -> Vec<ToolDef> {
"auto_rotate": { "type": "boolean" },
"booklet_subset": { "type": "string", "enum": ["both", "front", "back"] },
"binding": { "type": "string", "enum": ["left", "right"] },
"creep": { "type": "number", "minimum": 0, "maximum": 10, "description": "Booklet creep compensation: points each sheet's pages move toward the fold, times the sheet's place from the outside (default 0)." },
"overlap": { "type": "number", "minimum": 0 },
"cut_marks": { "type": "boolean" },
"orientation": { "type": "string", "enum": ["auto", "portrait", "landscape"] },
@@ -753,9 +781,9 @@ pub fn tools() -> Vec<ToolDef> {
"printer": { "type": "string" },
"copies": { "type": "integer", "minimum": 1, "maximum": 999 },
"collate": { "type": "boolean" },
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer sends no setting, so the printer's own default applies." },
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer uses the printer's current default: Windows sends its duplex setting when supported, otherwise preserves the driver default, including manual two-sided preferences; CUPS leaves the setting unspecified." },
"grayscale": { "type": "boolean" },
"as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey." },
"as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey, so the printer isn't asked for grayscale (a driver without that setting still prints them)." },
"dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." },
"options": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Printer driver options from printer_options: key → choice value." },
}),
@@ -834,7 +862,7 @@ 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 with its zoom). A relative url also has resolved_url, the address resolved against the document's base URI (catalog /URI /Base); a link active only in its /QuadPoints has active_areas, one rect per quadrilateral. With text_links: true, also the web and email addresses written as plain page text with no link over them, which the viewer opens on a click: page, rects (one per line), url, and blocked (why it won't open) when the link check refuses it. Only http, https and mailto addresses are found. At most 100 pages are read per call, from text_links_from (default 1); text_links_next_page then gives the page to continue from.")
.ro()
.with(schema(json!({ "doc": doc(), "text_links": { "type": "boolean" }, "text_links_from": { "type": "integer", "minimum": 1 } }), &["doc"])),
t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.")
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. A named destination is looked up in the target, as the app does; one the target doesn't define is reported with a warning. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }),
&["doc", "page", "index"],
@@ -887,7 +915,7 @@ pub fn tools() -> Vec<ToolDef> {
.ro().cmd("measure.snap").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"tolerance":{"type":"number","minimum":0,"maximum":10000},"endpoints":{"type":"boolean"},"midpoints":{"type":"boolean"},"intersections":{"type":"boolean"},"paths":{"type":"boolean"}}), &["doc","page","at"])),
t("measure_export", "Export measurements as CSV", "Atomically write saved measurement values, labels, authors and scale ratios as spreadsheet-safe CSV. Returns how many unsupported measurements were left out.")
.cmd("measure.export").with(schema(json!({"doc":doc(),"out":path_arg()}), &["doc","out"])),
t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark).")
t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark); the points that shape a line, polygon or callout, and a callout's text box. Annotations grouped with a comment (/RT /Group) are listed once, as that comment with its attributes, and named in its `group`. comment_delete deletes just the member named, as Acrobat does (Replace Text's strikeout and caret go together).")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])),
t(
@@ -965,7 +993,7 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("sign.digital")
.with(schema(json!({}), &[])),
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys). On a dynamic (XFA) form, run_form_scripts: true runs the field's own signing scripts: preSign just before signing, so what it changes is signed too (form_scripts.changed lists each field it changed, with the values before and after), and postSign after, left unsaved. Without it they don't run, and form_scripts.skipped says the field has them.")
.cmd("sign.digital")
.with(schema(
json!({
@@ -1016,6 +1044,7 @@ pub fn tools() -> Vec<ToolDef> {
"additionalProperties": false,
},
"out": { "type": "string", "description": "Where to save the signed document." },
"run_form_scripts": { "type": "boolean", "description": "Run a dynamic form's preSign and postSign scripts for the field (default false)." },
}),
&["doc", "id", "out"],
)),
@@ -1058,7 +1087,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
&["doc"],
)),
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema(
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp, or a callout's text box: its arrow point stays and its leader line re-attaches), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, the corners of connected lines, a polygon or a cloud, or a callout's arrow point and knee, as comment_list gives them). Stamps keep their original appearance when resized. One undo step.").with(schema(
comment_ref(json!({
"contents": { "type": "string" },
"color": color(),
@@ -1072,13 +1101,18 @@ pub fn tools() -> Vec<ToolDef> {
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"move": point(),
"points": { "type": "array", "items": point(), "minItems": 2, "description": "A line's two ends, or every corner of connected lines, a polygon or a cloud, in page coordinates." },
"points": { "type": "array", "items": point(), "minItems": 1, "description": "A line's two ends, every corner of connected lines, a polygon or a cloud, or a callout's arrow point and (optionally) knee, in page coordinates. A callout given its arrow point alone keeps the knee it has." },
})),
&["doc"],
)),
t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
.ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])),
t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
t("comment_popup", "Open, close or move a comment's pop-up", "Open or close a comment's pop-up window (open), or move it (rect, in page view coordinates as comment_list reports). A comment without a pop-up gets one beside it. Text boxes, Fill & Sign marks and replies have none of their own. Undoable.")
.with(schema(comment_ref(json!({
"open": { "type": "boolean" },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
})), &["doc"])),
t(
"comments_copy",
"Copy comments",
@@ -1213,13 +1247,27 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" }, "replace_backdrop": { "type": "boolean", "description": "Replace a white backdrop the page paints itself, which would hide the background. Default true." } }),
&["doc"],
)),
t("doc_printer_marks", "Add printer marks", "Print production: trim marks, bleed marks (dashed, at the bleed box), registration targets, colour bars and a page-information line (label, page n of N, date) drawn outside the trim box as a removable page mark, in registration colour (Separation All, so on every plate) except the process-ink colour bars; the page is widened by make_room points a side when the marks would fall outside it (the trim box stays). replace (default true) replaces marks added before. Undoable; doc_remove_marks kind printer_marks takes them off.")
.cmd("prepress.marks")
.with(schema(
json!({
"doc": doc(), "pages": pages("the pages to mark (default all)"),
"trim": { "type": "boolean" }, "bleed": { "type": "boolean" }, "registration": { "type": "boolean" }, "color_bars": { "type": "boolean" }, "page_info": { "type": "boolean" },
"line_weight": { "type": "number", "minimum": 0.1, "maximum": 4, "description": "Points (Acrobat offers 0.25, 0.5 and 1)." },
"make_room": { "type": "number", "maximum": 288, "description": "Points to widen the page by on each side when needed (default 36); negative leaves the page as it is." },
"label": { "type": "string", "description": "The page-information line's label (default: the file's name)." },
"date": { "type": "string", "description": "The page-information line's date (default: today)." },
"replace": { "type": "boolean" },
}),
&["doc"],
)),
t("doc_backdrops", "List page backdrops", "Pages that paint their own backdrop, which hides a background added behind the content: `fill` (an opaque white fill doc_background can replace) or `image` (a page-sized picture, e.g. a scan, that still hides it). Pages are 1-based; pages without one are left out.")
.ro()
.cmd("edit.background")
.with(schema(json!({ "doc": doc(), "pages": pages("to check (default: all)") }), &["doc"])),
t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.")
t("doc_remove_marks", "Remove header & footer, watermark, background or printer marks", "Remove every header and footer, watermark, background or printer marks PdfCraft (or a compatible tool) added. Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])),
.with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background", "printer_marks"] } }), &["doc", "kind"])),
t("doc_export_images", "Export pages as images", "Write pages as PNG, JPEG or TIFF files (`<name>_page_<n>.png|jpg|tif`) into a folder, at a resolution (default 150 dpi). JPEG and TIFF are flattened onto white paper. A page too large for the renderer at that resolution is drawn at the most it allows, listed in `lower_dpi` ({page, dpi}). Includes unsaved edits.")
.cmd("export.image")
.with(schema(
@@ -1355,7 +1403,7 @@ pub fn tools() -> Vec<ToolDef> {
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
.cmd("form.merge_data")
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.")
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit, attachment) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.")
.cmd("tools.js_console")
.with(schema(
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),
@@ -1685,7 +1733,7 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.")
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, search_index (removes the catalog's /PieceInfo entries whose application name contains \"index\" in any letter case; search_indexes), print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); discard_external_refs takes GoToR, GoToE, Launch and ImportData actions, and Thread actions with /F, out of links and bookmarks, removes a link left with nothing to do, and keeps links within the document and web links (external_links, external_bookmarks); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.")
.cmd("optimize.advanced")
.with(schema(
json!({
@@ -1701,6 +1749,7 @@ pub fn tools() -> Vec<ToolDef> {
"discard_thumbnails": { "type": "boolean" },
"discard_alternate_images": { "type": "boolean" },
"discard_tags": { "type": "boolean" },
"discard_search_index": { "type": "boolean" },
"discard_print_settings": { "type": "boolean" },
"discard_form_actions": { "type": "boolean" },
"flate_unencoded": { "type": "boolean" },
@@ -1708,6 +1757,7 @@ pub fn tools() -> Vec<ToolDef> {
"lzw_to_flate": { "type": "boolean" },
"remove_invalid_links": { "type": "boolean" },
"remove_unreferenced_dests": { "type": "boolean" },
"discard_external_refs": { "type": "boolean" },
"discard": { "type": "array", "items": { "type": "string" } },
}),
&["doc", "path"],
File diff suppressed because it is too large. Load diff
+1
View File
@@ -16,6 +16,7 @@ mod list_selection;
mod model_3d;
mod model_render;
mod optimize_content_boundaries;
mod portfolio_info;
mod redact_after_stray_q_form;
mod redact_text_after_q;
mod render_warnings;
@@ -0,0 +1,54 @@
//! `doc_info` reports a PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): the attachment it
//! presents first and how its file list first shows.
use pdfcraft_automation::{Automation, Content};
use serde_json::{Value, json};
/// Call tool `name` and return its JSON result.
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
match a.call(name, &args).unwrap().remove(0) {
Content::Json(value) => value,
other => panic!("expected JSON: {other:?}"),
}
}
/// A one-page portfolio of `cover.txt` and `form.pdf` with `collection` as its `/Collection`
/// entries, or none at all.
fn portfolio(collection: Option<&str>) -> Vec<u8> {
let collection = collection.map(|c| format!("/Collection << /Type /Collection {c} >>")).unwrap_or_default();
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R {collection} /Names << /EmbeddedFiles << /Names [(cover.txt) 5 0 R (form.pdf) 7 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] >> endobj
4 0 obj << /Type /EmbeddedFile /Length 5 >> stream
hello
endstream endobj
5 0 obj << /Type /Filespec /F (cover.txt) /UF (cover.txt) /EF << /F 4 0 R >> >> endobj
6 0 obj << /Type /EmbeddedFile /Length 8 >> stream
%PDF-1.7
endstream endobj
7 0 obj << /Type /Filespec /F (form.pdf) /UF (form.pdf) /EF << /F 6 0 R >> >> endobj
trailer << /Root 1 0 R >>
%%EOF"
)
.into_bytes()
}
/// `doc_info` names a portfolio's initial document and view, and reports none for a plain file.
#[test]
fn doc_info_names_the_document_a_portfolio_presents_first() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("tile.pdf"), portfolio(Some("/D (form.pdf) /View /T"))).unwrap();
std::fs::write(dir.path().join("cover.pdf"), portfolio(Some(""))).unwrap();
std::fs::write(dir.path().join("plain.pdf"), portfolio(None)).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let mut info = |path: &str| {
let doc = tool(&mut auto, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap();
tool(&mut auto, "doc_info", json!({ "doc": doc }))["portfolio"].clone()
};
assert_eq!(info("tile.pdf"), json!({ "initial": "form.pdf", "view": "tile" }));
// Without `/D` the cover sheet is the initial document.
assert_eq!(info("cover.pdf"), json!({ "initial": null, "view": "details" }));
assert_eq!(info("plain.pdf"), Value::Null);
}
@@ -123,6 +123,45 @@ fn page_render_reports_a_recovered_malformed_indexed_image() {
let _ = std::fs::remove_dir_all(dir);
}
/// A picture whose JBIG2 soft mask is past one of the decoder's limits isn't drawn (#6722), and
/// `page_render` says why the page is incomplete, in the words `pdfcraft-cli render` prints.
#[test]
fn page_render_says_a_picture_was_left_out_for_its_jbig2_mask() {
let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-jbig2-mask-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// One JBIG2 segment (ISO 14492 §7.4.8): the information of a page 65,536 columns wide, a
// column past the decoder's bound on a side, as the mask's dictionary says too.
let mut mask = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19];
mask.extend_from_slice(&65536u32.to_be_bytes());
mask.extend_from_slice(&2u32.to_be_bytes());
mask.extend_from_slice(&[0; 11]); // resolution, flags, striping
let content = "q 200 0 0 200 0 0 cm /Im Do Q";
let mut pdf = 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 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 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 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 6 0 R /Length 1 >> stream\n\0\nendstream endobj\n\
6 0 obj << /Type /XObject /Subtype /Image /Width 65536 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n",
content.len(),
mask.len()
)
.into_bytes();
pdf.extend_from_slice(&mask);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF");
std::fs::write(dir.join("mask.pdf"), pdf).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = open(&mut a, "mask.pdf");
let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") };
assert_eq!(
report["warnings"],
json!(["content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped"])
);
let _ = std::fs::remove_dir_all(dir);
}
/// Over MCP the warnings are a text block after the image and the call's `structuredContent`.
#[cfg(feature = "mcp")]
#[test]
+161 -2
View File
@@ -246,12 +246,14 @@ pub struct DecodeBudget {
bytes: usize,
forms: usize,
seen: HashSet<ObjRef>,
/// Decodes refused for lack of allowance ([`DecodeBudget::refused`]).
refusals: usize,
}
impl DecodeBudget {
/// An allowance of `bytes` decoded bytes and `forms` form entries.
pub fn new(bytes: usize, forms: usize) -> Self {
Self { limit: bytes, bytes, forms, seen: HashSet::new() }
Self { limit: bytes, bytes, forms, seen: HashSet::new(), refusals: 0 }
}
/// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms.
@@ -264,6 +266,22 @@ impl DecodeBudget {
self.bytes
}
/// How many decodes were refused for lack of allowance: one that came once it was spent, or
/// ran past what was left. A decode that spent exactly what was left wasn't refused, so an
/// allowance used up to the byte has none.
pub fn refused(&self) -> usize {
self.refusals
}
/// Whether the allowance is spent and `stream`, which has data, would be refused, noting the
/// refusal ([`DecodeBudget::refused`]): a caller asks this before resolving the stream's
/// parameters, work the allowance doesn't count. A stream without data still reads.
pub fn refuses(&mut self, stream: &Stream) -> bool {
let refuses = self.bytes == 0 && !stream.raw.is_empty();
self.refusals += usize::from(refuses);
refuses
}
/// Whether a decode was refused for lack of allowance or a form entry for lack of visits.
pub fn exhausted(&self) -> bool {
self.bytes == 0 || self.forms == 0
@@ -283,6 +301,51 @@ impl DecodeBudget {
self.charge(stream, max, Stream::decoded_within_counted).map(|(data, _)| data)
}
/// Like [`DecodeBudget::decode_within`], with every filter stage held to `max`, as
/// [`Stream::decoded_within`] holds it, and the stages together to `max` for each of them, the
/// stream's `/DecodeParms` given by reference read in `doc` through a view of it that shares
/// its data and copies no more of its dictionary than its filters and parameters
/// ([`DecodeBudget::decode_with_damage`] reads it so). A
/// stream so reads here when it reads without an allowance and the allowance can pay for
/// what its stages decode, the output of one stage on top of another's. A stage past `max`
/// is an error that charges what the stages before it decoded and `max`, the work it took to
/// find out; past the stages' total it charges that total, and past what is left it spends
/// all of it, as [`DecodeBudget::decode_within`] does.
pub fn decode_stages_within(&mut self, doc: &Document, stream: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
// Before the parameters are resolved, work the allowance doesn't count.
if self.refuses(stream) {
return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()));
}
let stream = doc.decoding_view(stream);
let total = max.saturating_mul(stream.filters().len().max(1));
let room = total.min(self.bytes);
match stream.decoded_within_each_counted(max, room) {
Ok((data, _, spent)) => {
self.bytes = self.bytes.saturating_sub(spent);
Ok(data)
}
// A stage stopped at `max`, below what was left of the room.
Err((FilterError::LimitExceeded(_), spent)) if room.saturating_sub(spent) > max => {
self.bytes = self.bytes.saturating_sub(spent.saturating_add(max));
Err(CosError::Filter(FilterError::LimitExceeded(max).to_string()))
}
// At the stages' total, with more left.
Err((FilterError::LimitExceeded(_), _)) if total < self.bytes => {
self.bytes = self.bytes.saturating_sub(total);
Err(CosError::Filter(FilterError::LimitExceeded(total).to_string()))
}
Err((FilterError::LimitExceeded(_), _)) => {
self.bytes = 0;
self.refusals += 1;
Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()))
}
Err((e, spent)) => {
self.bytes = self.bytes.saturating_sub(spent);
Err(CosError::Filter(e.to_string()))
}
}
}
/// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), with the stream's
/// `/DecodeParms` given by reference read in `doc`, also saying whether the stream is damaged:
/// `true` when a filter stopped at corrupt data, so the bytes are only what decoded before it.
@@ -304,7 +367,7 @@ impl DecodeBudget {
/// pays for the bytes decoded before the error, so referencing a stream again and again
/// spends the allowance by the work each decode does.
fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<(Vec<u8>, bool), CosError> {
if self.bytes == 0 && !stream.raw.is_empty() {
if self.refuses(stream) {
return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()));
}
if max < self.bytes {
@@ -324,6 +387,7 @@ impl DecodeBudget {
}
Err((FilterError::LimitExceeded(_), _)) => {
self.bytes = 0;
self.refusals += 1;
Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()))
}
Err((e, spent)) => {
@@ -1025,4 +1089,99 @@ mod tests {
assert!(budget.decode_strict(&Document::new_empty(), &chained).is_ok());
assert_eq!(budget.remaining(), (10 << 20) - 3 * charged);
}
/// `decode_stages_within` holds every stage to the limit, as a decode without an allowance
/// does, and the stages together to the limit for each of them. A stream behind ASCIIHex and
/// Flate whose stages each stay within it reads, the hex stage's output charged on top; one
/// whose Flate stage would make more stops there, charged what the hex stage made and the
/// limit, the rest of the allowance left; a Flate stage making hex text past the limit leaves
/// the stream unread though the total has room, as `Stream::decoded_within` leaves it; and a
/// stream past what is left spends all of it.
#[test]
fn each_stage_is_held_to_the_limit() {
let chained = |data: &[u8], filters: [&str; 2]| {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(filters.into_iter().map(Object::name).collect()));
Stream::from_raw(dict, data.to_vec())
};
let hexed = |data: &[u8]| {
let flate = Stream::flate(Dict::new(), data);
let hex: Vec<u8> = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect();
(chained(&hex, ["ASCIIHexDecode", "FlateDecode"]), flate.raw.len())
};
let max = 1 << 16;
let (fits, inner) = hexed(&vec![0u8; max]);
let mut budget = DecodeBudget::new(1 << 20, 0);
assert_eq!(budget.decode_stages_within(&Document::new_empty(), &fits, max).map(|d| d.len()).ok(), Some(max));
assert_eq!(budget.remaining(), (1 << 20) - inner - max);
let (bomb, inner) = hexed(&vec![0u8; 8 * max]);
let mut budget = DecodeBudget::new(1 << 20, 0);
assert!(budget.decode_stages_within(&Document::new_empty(), &bomb, max).is_err());
assert_eq!(budget.remaining(), (1 << 20) - inner - max, "the Flate stage stopped at the limit");
// Flate making 1.2 times the limit of hex text, which ASCIIHex halves: the stages together
// make 1.8 times the limit, within twice it, but the Flate stage alone is past it.
let text = "00".repeat(max * 6 / 10).into_bytes();
let wide = chained(&Stream::flate(Dict::new(), &text).raw, ["FlateDecode", "ASCIIHexDecode"]);
assert!(wide.decoded_within(max).is_err());
assert!(DecodeBudget::new(1 << 20, 0).decode_stages_within(&Document::new_empty(), &wide, max).is_err());
let mut budget = DecodeBudget::new(max, 0);
assert!(budget.decode_stages_within(&Document::new_empty(), &fits, max).is_err());
assert_eq!(budget.remaining(), 0);
}
/// What `decode_stages_within` charges, and which limit it reports, on each way a read stops:
/// a stage at the stream's limit with more of the room left charges what came before and the
/// limit; at the stages' total, with more of the allowance left, it charges that total; at
/// what is left of the allowance it spends all of it and reports the allowance's limit, as
/// `decode_within` does. Each boundary between them is read on both sides.
#[test]
fn a_stage_past_its_limit_charges_what_came_before_and_the_limit() {
let max = 1 << 12;
let hex = |data: &[u8]| data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect::<Vec<u8>>();
let two = |raw: &[u8]| {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
Stream::from_raw(dict, hex(raw))
};
let says = |read: Result<Vec<u8>, CosError>, limit: usize| {
read.err().map(|e| e.to_string()) == Some(CosError::Filter(FilterError::LimitExceeded(limit).to_string()).to_string())
};
// Flate data making eight times the limit: the hex stage makes `inner` bytes of it, and
// the Flate stage stops at the limit.
let bomb = Stream::flate(Dict::new(), &vec![0u8; 8 * max]).raw.to_vec();
let inner = bomb.len();
// A byte more of the room than the hex stage and the limit: the Flate stage stops at the
// limit, so that is what it charges.
let mut budget = DecodeBudget::new(inner + max + 1, 0);
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), max));
assert_eq!(budget.remaining(), 1);
// Exactly the hex stage and the limit: what was left stopped it as much as the limit did,
// so it is past the allowance, spends it and reports the allowance's limit.
let mut budget = DecodeBudget::new(inner + max, 0);
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), inner + max));
assert_eq!(budget.remaining(), 0);
// The same data padded to exactly the limit: the hex stage makes all of it, and the Flate
// stage, at the limit, reaches the stages' total. With more of the allowance left, that
// total is what it charges and reports.
let mut padded = bomb.clone();
padded.resize(max, 0);
let mut budget = DecodeBudget::new(10 * max, 0);
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 2 * max));
assert_eq!(budget.remaining(), 8 * max);
// With exactly the total left (of an allowance of three times the limit), it is past the
// allowance: all of it is spent, and the allowance's limit reported.
let mut budget = DecodeBudget::new(3 * max, 0);
budget.decode(&Stream::from_raw(Dict::new(), vec![0; max])).unwrap();
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 3 * max));
assert_eq!(budget.remaining(), 0);
// With less than the total left, the same.
let mut budget = DecodeBudget::new(max, 0);
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), max));
assert_eq!(budget.remaining(), 0);
// With nothing left, a stream with data is refused before it is decoded, even one that
// decodes to nothing; an empty one reads.
let mut budget = DecodeBudget::new(0, 0);
assert!(says(budget.decode_stages_within(&Document::new_empty(), &Stream::flate(Dict::new(), b""), max), 0));
assert_eq!(budget.decode_stages_within(&Document::new_empty(), &Stream::from_raw(Dict::new(), Vec::new()), max).ok(), Some(Vec::new()));
}
}
+1
View File
@@ -19,6 +19,7 @@ mod reader;
mod state;
mod xref;
pub use objects::DECODE_PARMS_KEYS;
pub(crate) use objects::Held;
#[cfg(test)]
pub(crate) use reader::find;
+1 -12
View File
@@ -507,17 +507,6 @@ impl Document {
pdfcraft_filters::decode_ccitt_within(&data, &p, rows, work).map_err(filter)
}
/// The `/JBIG2Globals` stream a JBIG2 image `s` names by reference in its last filter's
/// `/DecodeParms`: `None` for a stream whose last filter isn't `JBIG2Decode`, or one without
/// globals. Redaction finds with it the other images that share a redacted image's globals.
pub fn jbig2_globals_ref(&self, s: &Stream) -> Option<ObjRef> {
let chain = s.readable_filters().ok()?;
if !matches!(chain.last(), Some((pdfcraft_filters::Filter::Jbig2, _))) {
return None;
}
self.last_filter_parms(s).get(b"JBIG2Globals").and_then(Object::as_ref)
}
/// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`)
/// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one (a reference to no object is
/// one to null, ISO 32000-2 §7.3.10, which is an absent entry, §7.3.7). The image is then read
@@ -1079,7 +1068,7 @@ impl Document {
/// The `/DecodeParms` entries the decoders read (ISO 32000-2 Tables 8, 11 and 13: the Flate and
/// LZW predictors, CCITT, DCT's colour transform): numbers and flags a file may give by
/// reference, which [`Dict::int`] doesn't follow.
const DECODE_PARMS_KEYS: [&[u8]; 13] = [
pub const DECODE_PARMS_KEYS: [&[u8]; 13] = [
b"Predictor",
b"Colors",
b"BitsPerComponent",
+21
View File
@@ -1,5 +1,26 @@
use super::*;
/// What [`crate::reachable_objects`] gives is what a full save keeps: not an object nothing refers to, nor one
/// a reference only dangles at, and a ring of references ends.
#[test]
fn what_is_reachable_is_what_a_full_save_keeps() {
let mut doc = Document::new_empty();
let root = doc.root().unwrap();
let kept = doc.add(Object::Int(7));
let dangling = ObjRef::new(4_000, 0);
let list = doc.add(Object::Null);
let ring = doc.add(Object::Array(vec![Object::Ref(list)]));
doc.set(list, Object::Array(vec![Object::Ref(kept), Object::Ref(ring), Object::Ref(dangling)]));
let orphan = doc.add(Object::Int(8));
doc.update_dict(root, |d| d.set(b"Held".to_vec(), Object::Ref(list))).unwrap();
let found = crate::reachable_objects(&doc).unwrap();
assert!([root, kept, list, ring].iter().all(|r| found.contains(&r.num)));
assert!(!found.contains(&orphan.num) && !found.contains(&dangling.num));
let saved = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
let holds = |n: i64| saved.scan_objects().any(|(_, o)| matches!(*o, Object::Int(v) if v == n));
assert!(holds(7) && !holds(8));
}
std::thread_local! {
/// How many times `Document::scan_for` read the file on this thread.
pub(super) static FILE_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
+1 -1
View File
@@ -24,7 +24,7 @@ mod writer;
pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS, MAX_REDACTED_IMAGE_PIXELS};
pub use bytes::Bytes;
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
pub use document::{DECODE_PARMS_KEYS, Document, ReadState, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream, TextChars};
pub use parser::{Lexer, is_whitespace, parse_indirect};
pub use pdfcraft_crypt::{
+31 -2
View File
@@ -351,10 +351,12 @@ impl Stream {
}
/// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data
/// decoded before the damage is returned). Streams with image codecs fail with
/// decoded before the damage is returned). Decoding past
/// [`crate::budget::MAX_PAGE_DECODED`] (256 MiB), the most the renderer reads from one
/// stream, is an error. Streams with image codecs fail with
/// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage.
pub fn decoded(&self) -> Result<Vec<u8>, CosError> {
self.decoded_within(MAX_DECODED)
self.decoded_within(crate::budget::MAX_PAGE_DECODED)
}
/// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than
@@ -382,6 +384,26 @@ impl Stream {
self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent))
}
/// [`Stream::decoded_within_counted`] with every filter stage also held to `each`, as
/// [`Stream::decoded_within`] holds it, while `max` bounds the stages together
/// ([`pdfcraft_filters::decode_tolerant_counted_each`]). Without filters the data is held to
/// both.
pub(crate) fn decoded_within_each_counted(
&self,
each: usize,
max: usize,
) -> Result<(Vec<u8>, bool, usize), (pdfcraft_filters::FilterError, usize)> {
let mut damaged = false;
let decode = |chain: &[(Filter, Params)], data: &[u8], max| {
pdfcraft_filters::decode_tolerant_counted_each(chain, data, each.min(MAX_DECODED), max).map(|(v, partial, spent)| {
damaged = partial;
(v, spent)
})
};
let max = if self.readable_filters().is_ok_and(|chain| chain.is_empty()) { max.min(each) } else { max };
self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent))
}
/// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a
/// document's images ([`pdfcraft_filters::decode_tolerant_within`]).
pub(crate) fn decoded_spending(&self, max: usize, work: &mut u64) -> Result<Vec<u8>, CosError> {
@@ -780,6 +802,13 @@ mod tests {
assert_eq!(stream.decoded_strict().unwrap(), b"A");
}
#[test]
fn decoded_stops_at_renderer_cap_of_256_mib() {
let data = vec![0u8; (256 << 20) + 1];
let stream = Stream::flate(Dict::new(), &data);
assert!(matches!(stream.decoded(), Err(CosError::Filter(_))));
}
#[test]
fn first_filter_decodes_only_the_outer_wrapper() {
let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes();
+1
View File
@@ -16,6 +16,7 @@ md-5 = "0.11"
sha2 = "0.11"
stringprep = "0.1"
thiserror.workspace = true
zeroize.workspace = true
[dev-dependencies]
proptest.workspace = true
+58 -53
View File
@@ -19,6 +19,7 @@ mod rc4;
use md5::{Digest as _, Md5};
use sha2::{Sha256, Sha384, Sha512};
use zeroize::Zeroizing;
pub use aes_cbc::{aes_cbc_decrypt, aes_cbc_encrypt};
pub use rc4::rc4;
@@ -147,9 +148,9 @@ pub enum StreamKind<'a> {
#[derive(Clone, Debug)]
pub struct SecurityHandler {
dict: EncryptDict,
key: Vec<u8>,
key: Zeroizing<Vec<u8>>,
auth: Auth,
recovered_user: Option<Vec<u8>>,
recovered_user: Option<Zeroizing<Vec<u8>>>,
methods: Methods,
}
@@ -190,7 +191,7 @@ impl Methods {
}
impl SecurityHandler {
fn new(dict: EncryptDict, key: Vec<u8>, auth: Auth, recovered_user: Option<Vec<u8>>) -> Self {
fn new(dict: EncryptDict, key: Zeroizing<Vec<u8>>, auth: Auth, recovered_user: Option<Zeroizing<Vec<u8>>>) -> Self {
let methods = Methods::new(&dict);
Self { dict, key, auth, recovered_user, methods }
}
@@ -248,7 +249,7 @@ impl SecurityHandler {
if methods.string != Method::Identity || methods.stream != Method::Identity {
return None;
}
Some(Self { dict, key: Vec::new(), auth: Auth::User, recovered_user: None, methods })
Some(Self { dict, key: Zeroizing::new(Vec::new()), auth: Auth::User, recovered_user: None, methods })
}
/// Whether the handler has the file key: false for [`SecurityHandler::without_key`].
@@ -289,7 +290,7 @@ impl SecurityHandler {
/// For R2–R4 documents opened with the owner password: the user password, which owner
/// authentication recovers (Algorithm 7). Lets components that only accept user passwords
/// open the document too.
pub fn recovered_user_password(&self) -> Option<Vec<u8>> {
pub fn recovered_user_password(&self) -> Option<Zeroizing<Vec<u8>>> {
self.recovered_user.clone()
}
@@ -306,7 +307,7 @@ impl SecurityHandler {
if !matches!(self.dict.r, 5 | 6) || !self.has_key() {
return None;
}
let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false);
let plain = Zeroizing::new(aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false));
let p = i32::from_le_bytes(*plain.first_chunk::<4>()?);
(plain.get(9..12) == Some(&b"adb"[..])).then_some(p)
}
@@ -348,7 +349,7 @@ impl SecurityHandler {
/// the same way, so a page it shows is one this crate can read and save. AES-128 keys are
/// left as they are: `AESV2` takes a 128-bit key (Table 25), and Acrobat's extension is only
/// seen with RC4. Every key PdfCraft writes under `/V 4` is 16 bytes already.
fn object_key(&self, method: Method, num: u32, generation: u16) -> Vec<u8> {
fn object_key(&self, method: Method, num: u32, generation: u16) -> Zeroizing<Vec<u8>> {
if method == Method::Aes256 {
return self.key.clone();
}
@@ -363,8 +364,9 @@ impl SecurityHandler {
if method == Method::Aes128 {
h.update(b"sAlT");
}
let digest = h.finalize();
digest[..(key.len() + 5).min(16)].to_vec()
let mut digest = Zeroizing::new(h.finalize().to_vec());
digest.truncate((key.len() + 5).min(16));
digest
}
fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec<u8> {
@@ -456,31 +458,31 @@ pub fn pdfdoc_byte(c: char) -> Option<u8> {
/// Revisions 2–4: the password in PDFDocEncoding (§7.6.4.3.2, Algorithm 2), at most 32 bytes.
/// Characters PDFDocEncoding lacks are dropped.
fn legacy_password(pw: &str) -> Vec<u8> {
pw.chars().filter_map(pdfdoc_byte).take(32).collect()
fn legacy_password(pw: &str) -> Zeroizing<Vec<u8>> {
Zeroizing::new(pw.chars().filter_map(pdfdoc_byte).take(32).collect())
}
/// The bytes an R2–R4 password may have been written as: PDFDocEncoding, as the specification
/// asks; and for a password with characters PDFDocEncoding lacks (ğ, ş, İ, …), which it gives
/// no bytes, also its UTF-8 bytes, which qpdf and other tools write for such passwords.
fn legacy_candidates(pw: &str) -> Vec<Vec<u8>> {
fn legacy_candidates(pw: &str) -> Vec<Zeroizing<Vec<u8>>> {
let mut out = vec![legacy_password(pw)];
if pw.chars().any(|c| pdfdoc_byte(c).is_none()) {
out.push(pw.bytes().take(32).collect());
out.push(Zeroizing::new(pw.bytes().take(32).collect()));
}
out
}
/// Revisions 5–6: SASLprep (RFC 4013), UTF-8, at most 127 bytes.
fn sasl_password(pw: &str) -> Vec<u8> {
let prepped = stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string());
let mut bytes = prepped.into_bytes();
fn sasl_password(pw: &str) -> Zeroizing<Vec<u8>> {
let prepped = Zeroizing::new(stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string()));
let mut bytes = Zeroizing::new(prepped.as_bytes().to_vec());
bytes.truncate(127);
bytes
}
fn pad(pw: &[u8]) -> [u8; 32] {
let mut out = PADDING;
fn pad(pw: &[u8]) -> Zeroizing<[u8; 32]> {
let mut out = Zeroizing::new(PADDING);
let n = pw.len().min(32);
out[..n].copy_from_slice(&pw[..n]);
out[n..].copy_from_slice(&PADDING[..32 - n]);
@@ -494,20 +496,20 @@ fn key_len(d: &EncryptDict) -> usize {
// ── Revisions 2–4 ──────────────────────────────────────────────────────────────────────────────
/// Algorithm 2: file key from a (user) password.
fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Vec<u8> {
fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Zeroizing<Vec<u8>> {
let n = key_len(d);
let mut h = Md5::new();
h.update(pad(pw));
h.update(*pad(pw));
h.update(&d.o[..d.o.len().min(32)]);
h.update(d.p.to_le_bytes());
h.update(id0);
if d.r >= 4 && !d.encrypt_metadata {
h.update([0xFF; 4]);
}
let mut digest = h.finalize().to_vec();
let mut digest = Zeroizing::new(h.finalize().to_vec());
if d.r >= 3 {
for _ in 0..50 {
digest = Md5::digest(&digest[..n]).to_vec();
digest = Zeroizing::new(Md5::digest(&digest[..n]).to_vec());
}
}
digest.truncate(n);
@@ -524,7 +526,7 @@ fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec<u8> {
h.update(id0);
let mut x = rc4(key, &h.finalize());
for i in 1..=19u8 {
let k: Vec<u8> = key.iter().map(|b| b ^ i).collect();
let k = Zeroizing::new(key.iter().map(|b| b ^ i).collect::<Vec<u8>>());
x = rc4(&k, &x);
}
x.extend_from_slice(&[0u8; 16]);
@@ -536,7 +538,7 @@ fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec<u8> {
/// Some producers ignore `/EncryptMetadata false` when deriving the key (they omit the
/// `FF FF FF FF` of Algorithm 2, step f). Viewers open those files, so if the strict derivation
/// fails we retry the other way.
fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<Vec<u8>> {
fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<Zeroizing<Vec<u8>>> {
let check = |d: &EncryptDict| {
let key = file_key_r4(d, id0, pw);
let u = u_value_r4(d, id0, &key);
@@ -547,54 +549,57 @@ fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<Vec<u8>> {
}
/// The RC4 key derived from an owner password (Algorithm 3, steps a–d).
fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Vec<u8> {
fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Zeroizing<Vec<u8>> {
let n = key_len(d);
let mut digest = Md5::digest(pad(owner_pw)).to_vec();
let mut digest = Zeroizing::new(Md5::digest(*pad(owner_pw)).to_vec());
if d.r >= 3 {
for _ in 0..50 {
digest = Md5::digest(&digest).to_vec();
digest = Zeroizing::new(Md5::digest(&*digest).to_vec());
}
}
digest.truncate(n);
digest
}
type SecretBytes = Zeroizing<Vec<u8>>;
/// Algorithm 7: authenticate an owner password (recover the user password from `/O`).
/// Returns the file key and the recovered user password (padding removed).
fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(SecretBytes, SecretBytes)> {
let rk = owner_rc4_key(d, pw);
let mut user = d.o[..d.o.len().min(32)].to_vec();
let mut user = Zeroizing::new(d.o[..d.o.len().min(32)].to_vec());
if d.r == 2 {
user = rc4(&rk, &user);
user = Zeroizing::new(rc4(&rk, &user));
} else {
for i in (0..=19u8).rev() {
let k: Vec<u8> = rk.iter().map(|b| b ^ i).collect();
user = rc4(&k, &user);
let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::<Vec<u8>>());
user = Zeroizing::new(rc4(&k, &user));
}
}
let key = user_key_r4(d, id0, &user)?;
// The recovered value is the padded password: strip the padding suffix.
let len = (0..=32).find(|&i| user.len() >= 32 && user[i..32] == PADDING[..32 - i]).unwrap_or(user.len().min(32));
Some((key, user[..len].to_vec()))
user.truncate(len);
Some((key, user))
}
// ── Revisions 5–6 ──────────────────────────────────────────────────────────────────────────────
/// The hash of a password for R5 (SHA-256) or R6 (Algorithm 2.B).
fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec<u8> {
let mut k = {
fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Zeroizing<Vec<u8>> {
let mut k = Zeroizing::new({
let mut h = Sha256::new();
h.update(pw);
h.update(salt);
h.update(udata);
h.finalize().to_vec()
};
});
if r == 5 {
return k;
}
let mut round = 0u32;
loop {
let mut k1 = Vec::with_capacity(64 * (pw.len() + k.len() + udata.len()));
let mut k1 = Zeroizing::new(Vec::with_capacity(64 * (pw.len() + k.len() + udata.len())));
for _ in 0..64 {
k1.extend_from_slice(pw);
k1.extend_from_slice(&k);
@@ -603,13 +608,13 @@ fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec<u8> {
// `k` is always a SHA-2 digest (32, 48 or 64 bytes): key = its first 16 bytes, IV = the next 16.
let Some((key, rest)) = k.split_first_chunk::<16>() else { break };
let Some(iv) = rest.first_chunk::<16>() else { break };
let e = aes_cbc_encrypt(key, iv, &k1, false);
let e = Zeroizing::new(aes_cbc_encrypt(key, iv, &k1, false));
let m = e.iter().take(16).map(|b| u32::from(*b)).sum::<u32>() % 3;
k = match m {
0 => Sha256::digest(&e).to_vec(),
1 => Sha384::digest(&e).to_vec(),
_ => Sha512::digest(&e).to_vec(),
};
k = Zeroizing::new(match m {
0 => Sha256::digest(&*e).to_vec(),
1 => Sha384::digest(&*e).to_vec(),
_ => Sha512::digest(&*e).to_vec(),
});
round += 1;
if round >= 64 && e.last().is_none_or(|b| u32::from(*b) <= round - 32) {
break;
@@ -620,19 +625,19 @@ fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec<u8> {
}
/// Algorithm 2.A: authenticate as owner (`owner = true`) or user; returns the file key.
fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result<Option<Vec<u8>>, CryptError> {
fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result<Option<Zeroizing<Vec<u8>>>, CryptError> {
if d.u.len() < 48 || d.o.len() < 48 {
return Err(CryptError::Malformed("/O and /U must be 48 bytes for R5/R6".into()));
}
let (hashed, encrypted, udata) = if owner { (&d.o[..48], &d.oe, &d.u[..48]) } else { (&d.u[..48], &d.ue, &[][..]) };
if hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] {
if *hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] {
return Ok(None);
}
if encrypted.len() < 32 {
return Err(CryptError::Malformed("/OE or /UE must be 32 bytes".into()));
}
let k = hash_r6(d.r, pw, &hashed[40..48], udata);
Ok(Some(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false)))
Ok(Some(Zeroizing::new(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false))))
}
// ── Creating encryption ────────────────────────────────────────────────────────────────────────
@@ -654,7 +659,7 @@ pub enum Algorithm {
/// dropped), at most 32 bytes; for R6 its SASLprep (RFC 4013: soft hyphens and the like removed,
/// other spaces made plain, then NFKC; the password as given if SASLprep refuses it), in UTF-8,
/// at most 127 bytes.
fn prepared_password(algorithm: Algorithm, password: &str) -> Vec<u8> {
fn prepared_password(algorithm: Algorithm, password: &str) -> Zeroizing<Vec<u8>> {
match algorithm {
Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => legacy_password(password),
Algorithm::Aes256 => sasl_password(password),
@@ -672,7 +677,7 @@ pub fn effective_password(algorithm: Algorithm, password: &str) -> Vec<u8> {
let prepared = prepared_password(algorithm, password);
match algorithm {
Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => pad(&prepared).to_vec(),
Algorithm::Aes256 => prepared,
Algorithm::Aes256 => prepared.to_vec(),
}
}
@@ -737,10 +742,10 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, Cry
let owner = if params.owner_password.is_empty() { user.clone() } else { owner };
// Algorithm 3: /O
let rk = owner_rc4_key(&d, &owner);
let mut o = rc4(&rk, &pad(&user));
let mut o = rc4(&rk, &*pad(&user));
if d.r >= 3 {
for i in 1..=19u8 {
let k: Vec<u8> = rk.iter().map(|b| b ^ i).collect();
let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::<Vec<u8>>());
o = rc4(&k, &o);
}
}
@@ -754,15 +759,15 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, Cry
d.crypt_filters = vec![(b"StdCF".to_vec(), Method::Aes256)];
d.stm_f = b"StdCF".to_vec();
d.str_f = b"StdCF".to_vec();
let key = expand(&params.seed, b"file key", 32);
let key = Zeroizing::new(expand(&params.seed, b"file key", 32));
let salts = expand(&params.seed, b"salts", 32);
// Algorithm 8: /U, /UE
let mut u = hash_r6(6, &user, &salts[0..8], &[]);
let mut u = hash_r6(6, &user, &salts[0..8], &[]).to_vec();
u.extend_from_slice(&salts[0..16]);
let uk = hash_r6(6, &user, &salts[8..16], &[]);
d.ue = aes_cbc_encrypt(&uk, &[0; 16], &key, false);
// Algorithm 9: /O, /OE
let mut o = hash_r6(6, &owner, &salts[16..24], &u);
let mut o = hash_r6(6, &owner, &salts[16..24], &u).to_vec();
o.extend_from_slice(&salts[16..32]);
let ok = hash_r6(6, &owner, &salts[24..32], &u);
d.oe = aes_cbc_encrypt(&ok, &[0; 16], &key, false);
+7 -5
View File
@@ -348,11 +348,13 @@ fn owner_authentication_recovers_the_user_password() {
for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
let d = new(alg, "user pw", "owner pw", -1).dict().clone();
let h = SecurityHandler::open(d.clone(), ID, Some("owner pw")).unwrap();
assert_eq!(h.recovered_user_password().as_deref(), Some(&b"user pw"[..]), "{alg:?}");
let _: &Zeroizing<Vec<u8>> = &h.key;
let _: &Option<Zeroizing<Vec<u8>>> = &h.recovered_user;
assert_eq!(h.recovered_user_password().as_deref().map(Vec::as_slice), Some(&b"user pw"[..]), "{alg:?}");
assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap().recovered_user_password(), None);
}
let d = new(Algorithm::Rc4_128, "", "owner", -1).dict().clone();
assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref(), Some(&b""[..]));
assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref().map(Vec::as_slice), Some(&b""[..]));
}
/// /O, /U and the first /ID of files qpdf 12.4.2 wrote (`--static-id`), with the file keys
@@ -393,7 +395,7 @@ fn legacy_passwords_are_pdfdoc_encoded_with_a_utf8_fallback() {
assert_eq!((user.auth(), user.file_key().to_vec()), (Auth::User, hex("3564ffcea186122595ad55ee157da71f")));
let owner = SecurityHandler::open(r3.clone(), &id, Some("Öğretmen")).unwrap();
assert_eq!((owner.auth(), owner.file_key().to_vec()), (Auth::Owner, hex("3564ffcea186122595ad55ee157da71f")));
assert_eq!(owner.recovered_user_password(), Some(vec![0x9A, b'l', 0x9A, b'k', 0xA0]));
assert_eq!(owner.recovered_user_password(), Some(Zeroizing::new(vec![0x9A, b'l', 0x9A, b'k', 0xA0])));
// Dropping ı and € (the old Latin-1 conversion) or a near miss doesn't open it.
for wrong in ["lk", "ilik€", "ılık", "Ogretmen"] {
assert_eq!(SecurityHandler::open(r3.clone(), &id, Some(wrong)).unwrap_err(), CryptError::WrongPassword, "{wrong}");
@@ -423,6 +425,6 @@ fn pdfdoc_encoding_round_trips() {
for c in ['ğ', 'ş', 'İ', '\u{A0}', '\u{85}', 'Ж', '日'] {
assert_eq!(pdfdoc_byte(c), None, "{c}");
}
assert_eq!(legacy_candidates("Çok"), [vec![0xC7, b'o', b'k']]);
assert_eq!(legacy_candidates("şifre"), [b"ifre".to_vec(), "şifre".as_bytes().to_vec()]);
assert_eq!(legacy_candidates("Çok"), [Zeroizing::new(vec![0xC7, b'o', b'k'])]);
assert_eq!(legacy_candidates("şifre"), [Zeroizing::new(b"ifre".to_vec()), Zeroizing::new("şifre".as_bytes().to_vec())]);
}
+2
View File
@@ -119,6 +119,8 @@ pub fn scan_markers(doc: &Document) -> MarkerScan {
Some(MarkKind::Watermark)
} else if tag == Some(&b"Background"[..]) {
Some(MarkKind::Background)
} else if tag == Some(&b"PrinterMarks"[..]) {
Some(MarkKind::PrinterMarks)
} else {
None
};
+230
View File
@@ -0,0 +1,230 @@
//! Colours as sRGB through the colour space in force, for Convert colours: `g`/`rg`/`k`, and
//! `cs` + `sc`/`scn` through the space named. Device spaces, ICCBased by its component count (or
//! its Alternate), CalRGB and CalGray as their device kin, Lab through CIE XYZ with the
//! dictionary's white point, Indexed through its table, Separation and DeviceN through a Type 2
//! (exponential) tint transform into the alternate space; a sampled or PostScript tint function,
//! or a pattern, falls back to a plain darkness so nothing reads as the wrong hue.
use pdfcraft_cos::{Dict, Document, Object};
/// A colour space as far as a fill colour needs it.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Space {
Gray,
Rgb,
Cmyk,
Lab {
white: [f64; 3],
range: [f64; 4],
},
Indexed {
base: Box<Space>,
hival: usize,
table: Vec<u8>,
},
/// Separation and DeviceN: the number of inputs, the alternate space, and the tint
/// transform when it is a Type 2 function (C0, C1, N).
Tint {
inputs: usize,
alt: Box<Space>,
exp: Option<(Vec<f64>, Vec<f64>, f64)>,
},
Pattern,
}
impl Space {
/// How many numbers set a colour in this space.
fn components(&self) -> usize {
match self {
Space::Gray => 1,
Space::Rgb | Space::Lab { .. } => 3,
Space::Cmyk => 4,
Space::Indexed { .. } => 1,
Space::Tint { inputs, .. } => *inputs,
Space::Pattern => 0,
}
}
}
fn nums(doc: &Document, o: &Object) -> Vec<f64> {
doc.resolve(o).as_array().map(|a| a.iter().filter_map(|v| doc.resolve(v).as_f64()).collect()).unwrap_or_default()
}
/// The colour space an operand of `cs` names: a device space, or a name in the resources'
/// `/ColorSpace` dictionary, or (inside arrays) a family.
pub(crate) fn space_of(doc: &Document, operand: &Object, cs_res: &Dict, depth: u8) -> Space {
if depth > 4 {
return Space::Gray;
}
let resolved = doc.resolve(operand);
match &*resolved {
Object::Name(n) => match n.as_slice() {
b"DeviceGray" | b"G" | b"CalGray" => Space::Gray,
b"DeviceRGB" | b"RGB" | b"CalRGB" => Space::Rgb,
b"DeviceCMYK" | b"CMYK" => Space::Cmyk,
b"Pattern" => Space::Pattern,
other => match cs_res.get(other) {
Some(o) => space_of(doc, o, &Dict::default(), depth + 1),
None => Space::Gray,
},
},
Object::Array(a) => {
let family = a.first().map(|f| doc.resolve(f)).and_then(|f| f.as_name().map(|n| n.to_vec())).unwrap_or_default();
match family.as_slice() {
b"ICCBased" => {
let stream = a.get(1).map(|s| doc.resolve(s));
let dict = stream.as_ref().and_then(|s| match &**s {
Object::Stream(st) => Some(st.dict.clone()),
_ => None,
});
if let Some(alt) = dict.as_ref().and_then(|d| d.get(b"Alternate").cloned()) {
return space_of(doc, &alt, cs_res, depth + 1);
}
match dict.and_then(|d| d.int(b"N")) {
Some(1) => Space::Gray,
Some(4) => Space::Cmyk,
_ => Space::Rgb,
}
}
b"CalRGB" => Space::Rgb,
b"CalGray" => Space::Gray,
b"Lab" => {
let d = a.get(1).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap_or_default();
let wp = d.get(b"WhitePoint").map(|w| nums(doc, w)).unwrap_or_default();
let white = if wp.len() == 3 { [wp[0], wp[1], wp[2]] } else { [0.9505, 1.0, 1.089] };
let r = d.get(b"Range").map(|w| nums(doc, w)).unwrap_or_default();
let range = if r.len() == 4 { [r[0], r[1], r[2], r[3]] } else { [-100.0, 100.0, -100.0, 100.0] };
Space::Lab { white, range }
}
b"Indexed" | b"I" => {
let base = a.get(1).map(|b| space_of(doc, b, cs_res, depth + 1)).unwrap_or(Space::Rgb);
let hival = a.get(2).and_then(|h| doc.resolve(h).as_f64()).unwrap_or(0.0).clamp(0.0, 255.0) as usize;
let table = match a.get(3).map(|t| doc.resolve(t)) {
Some(t) => match &*t {
Object::String(s) => s.bytes.clone(),
Object::Stream(st) => st.decoded().unwrap_or_default(),
_ => Vec::new(),
},
None => Vec::new(),
};
Space::Indexed { base: Box::new(base), hival, table }
}
b"Separation" | b"DeviceN" => {
// The `/None` colorant paints nothing (ISO 32000-2, 8.6.6.4): given a colour
// of its own it would appear, so it stays what it is, as a pattern does.
let is_none = |o: &Object| doc.resolve(o).as_name() == Some(b"None");
let none = a.get(1).is_some_and(|names| {
is_none(names) || doc.resolve(names).as_array().is_some_and(|names| !names.is_empty() && names.iter().all(is_none))
});
if none {
return Space::Pattern;
}
let inputs = if family == b"Separation" {
1
} else {
a.get(1).map(|n| doc.resolve(n)).and_then(|n| n.as_array().map(Vec::len)).unwrap_or(1).max(1)
};
let alt = a.get(2).map(|b| space_of(doc, b, cs_res, depth + 1)).unwrap_or(Space::Gray);
let exp = a.get(3).map(|f| doc.resolve(f)).and_then(|f| {
let d = match &*f {
Object::Dict(d) => d.clone(),
Object::Stream(s) => s.dict.clone(),
_ => return None,
};
if d.int(b"FunctionType") != Some(2) {
return None;
}
let c0 = d.get(b"C0").map(|v| nums(doc, v)).unwrap_or_else(|| vec![0.0]);
let c1 = d.get(b"C1").map(|v| nums(doc, v)).unwrap_or_else(|| vec![1.0]);
let n = d.get(b"N").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(1.0);
Some((c0, c1, n))
});
Space::Tint { inputs, alt: Box::new(alt), exp }
}
b"Pattern" => Space::Pattern,
b"DeviceGray" | b"G" => Space::Gray,
b"DeviceRGB" | b"RGB" => Space::Rgb,
b"DeviceCMYK" | b"CMYK" => Space::Cmyk,
_ => Space::Gray,
}
}
_ => Space::Gray,
}
}
fn clamp01(v: f64) -> f64 {
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
}
/// CMYK to sRGB, the usual multiplicative approximation (no ICC profile).
pub(crate) fn cmyk(c: f64, m: f64, y: f64, k: f64) -> [f64; 3] {
let (c, m, y, k) = (clamp01(c), clamp01(m), clamp01(y), clamp01(k));
[(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]
}
/// CIE L*a*b* (with the dictionary's white point) to sRGB, through XYZ and the D65 sRGB matrix.
fn lab(white: [f64; 3], l: f64, a: f64, b: f64) -> [f64; 3] {
let m = (l + 16.0) / 116.0;
let (ll, mm, nn) = (m + a / 500.0, m, m - b / 200.0);
let g = |x: f64| if x >= 6.0 / 29.0 { x * x * x } else { 108.0 / 841.0 * (x - 4.0 / 29.0) };
let (xw, yw, zw) = (white[0].max(1e-6), white[1].max(1e-6), white[2].max(1e-6));
let (x, y, z) = (xw * g(ll), yw * g(mm), zw * g(nn));
// Scale the white point to D65 before the sRGB matrix (a plain von Kries over XYZ).
let (x, y, z) = (x * 0.9505 / xw, y / yw, z * 1.089 / zw);
let lin = [3.2406 * x - 1.5372 * y - 0.4986 * z, -0.9689 * x + 1.8758 * y + 0.0415 * z, 0.0557 * x - 0.2040 * y + 1.0570 * z];
lin.map(|c| {
let c = clamp01(c);
if c <= 0.0031308 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
})
}
/// The sRGB of `values` in `space` (missing values read as 0; a pattern reads as black).
pub(crate) fn rgb(space: &Space, values: &[f64]) -> [f64; 3] {
let v = |i: usize| values.get(i).copied().unwrap_or(0.0);
match space {
Space::Gray => {
let g = clamp01(v(0));
[g, g, g]
}
Space::Rgb => [clamp01(v(0)), clamp01(v(1)), clamp01(v(2))],
Space::Cmyk => cmyk(v(0), v(1), v(2), v(3)),
Space::Lab { white, range } => {
let l = v(0).clamp(0.0, 100.0);
let a = v(1).clamp(range[0].min(range[1]), range[1].max(range[0]));
let b = v(2).clamp(range[2].min(range[3]), range[3].max(range[2]));
lab(*white, l, a, b)
}
Space::Indexed { base, hival, table } => {
let n = base.components().max(1);
let i = (v(0).max(0.0).round() as usize).min(*hival);
let vals: Vec<f64> = (0..n).map(|k| table.get(i * n + k).map(|b| *b as f64 / 255.0).unwrap_or(0.0)).collect();
// Lab tables hold the L*, a*, b* range in bytes; the others are 0..1 already.
match &**base {
Space::Lab { range, .. } => {
let scaled = [
vals[0] * 100.0,
range[0] + vals.get(1).copied().unwrap_or(0.0) * (range[1] - range[0]),
range[2] + vals.get(2).copied().unwrap_or(0.0) * (range[3] - range[2]),
];
rgb(base, &scaled)
}
_ => rgb(base, &vals),
}
}
Space::Tint { inputs, alt, exp } => match exp {
Some((c0, c1, n)) => {
let t = clamp01(v(0));
let k = if *n == 1.0 { t } else { t.powf(*n) };
let out: Vec<f64> = c0.iter().zip(c1.iter()).map(|(a, b)| a + k * (b - a)).collect();
rgb(alt, &out)
}
// No usable transform: the darkness of the strongest tint, in the alternate space's ink.
None => {
let t = (0..*inputs).map(v).fold(0.0, f64::max);
let g = 1.0 - clamp01(t);
[g, g, g]
}
},
Space::Pattern => [0.0, 0.0, 0.0],
}
}
+492
View File
@@ -0,0 +1,492 @@
//! Convert colours (Acrobat's Print production ▸ Convert Colors): every colour
//! operator in the page content and the artwork it draws is rewritten in a target space -
//! grayscale, RGB or CMYK - by the standard device formulas (no ICC profile is applied), black
//! preserved on request; pictures are re-encoded through a helper the caller supplies.
//!
//! This is not a prepress separation. No ICC profile, rendering intent or output intent is
//! used, so the numbers are not calibrated for any press, and the catalog's `/OutputIntents`
//! stay as they were. A PDF/A or PDF/X file may no longer conform afterwards, and still says it
//! does: nothing here changes what it declares (the `doc_convert_colours` tool lists what it
//! declared; `standards_remove_id` takes that off).
//!
//! Spot colours are converted, not kept: Separation and DeviceN through a Type 2 tint transform
//! into their alternate space, any other tint transform as a gray of the strongest tint's
//! darkness (the hue is lost). `/All` converts like any other; `/None` paints nothing and stays.
//! Patterns, shadings, inline images and transparency group spaces stay as they are.
//!
//! Soft masks are not touched: a picture keeps the one it has. The helper leaves a picture alone
//! when it cannot re-encode it (indexed, CMYK, not 8-bit, an explicit mask, a matted soft mask,
//! JPEG 2000 ...); those that are not in the target already are counted in
//! [`Converted::images_left`].
use pdfcraft_content::{Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use std::collections::{HashMap, HashSet};
use crate::colour::{Space, rgb, space_of};
use crate::page_streams::PageStreams;
use crate::{EditError, check, page_list};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ColourTarget {
Gray,
Rgb,
Cmyk,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ConvertColours {
pub target: ColourTarget,
/// Pure black stays 100% K (or gray 0) rather than a mix.
pub preserve_black: bool,
/// Re-encode pictures too (through the caller's helper).
pub images: bool,
/// The appearance streams of the pages' comments and form fields too.
pub annotations: bool,
}
impl Default for ConvertColours {
fn default() -> Self {
Self { target: ColourTarget::Gray, preserve_black: true, images: true, annotations: false }
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Converted {
/// Colour operators rewritten.
pub colours: usize,
/// Pictures re-encoded.
pub images: usize,
/// Pictures not in the target space that could not be re-encoded and are left as they were.
pub images_left: usize,
/// Pages on which something changed.
pub pages: usize,
}
/// Re-encode one image stream in the target space, if it can be: the caller's (the optimizer
/// knows how to decode and encode pictures; the edit crate does not).
pub type ImageRecolour<'a> = dyn FnMut(&Document, &Stream, ColourTarget) -> Option<Stream> + 'a;
fn luma(c: [f64; 3]) -> f64 {
(0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]).clamp(0.0, 1.0)
}
/// sRGB → the target's components.
pub fn components(target: ColourTarget, c: [f64; 3], preserve_black: bool) -> Vec<f64> {
let black = c.iter().all(|v| *v < 0.02);
match target {
ColourTarget::Gray => vec![if black && preserve_black { 0.0 } else { luma(c) }],
ColourTarget::Rgb => c.to_vec(),
ColourTarget::Cmyk => {
if black && preserve_black {
return vec![0.0, 0.0, 0.0, 1.0];
}
let k = 1.0 - c.iter().cloned().fold(0.0, f64::max);
if k >= 0.999 {
return vec![0.0, 0.0, 0.0, 1.0];
}
let f = |v: f64| ((1.0 - v - k) / (1.0 - k)).clamp(0.0, 1.0);
vec![f(c[0]), f(c[1]), f(c[2]), k]
}
}
}
fn op_for(target: ColourTarget, stroke: bool) -> &'static str {
match (target, stroke) {
(ColourTarget::Gray, false) => "g",
(ColourTarget::Gray, true) => "G",
(ColourTarget::Rgb, false) => "rg",
(ColourTarget::Rgb, true) => "RG",
(ColourTarget::Cmyk, false) => "k",
(ColourTarget::Cmyk, true) => "K",
}
}
/// Whether a `cs` operand names DeviceCMYK itself (directly or through a `/ColorSpace`
/// resource), whose initial colour is black, unlike an ICC CMYK space's all-zero white.
fn device_cmyk(doc: &Document, operand: &Object, cs_res: &Dict) -> bool {
let is_device = |o: &Object| match &*doc.resolve(o) {
Object::Name(n) => matches!(n.as_slice(), b"DeviceCMYK" | b"CMYK"),
Object::Array(a) => a.len() == 1 && a.first().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceCMYK" | b"CMYK")),
_ => false,
};
match operand.as_name() {
Some(n) => is_device(operand) || cs_res.get(n).is_some_and(is_device),
None => is_device(operand),
}
}
fn r3(v: f64) -> Object {
Object::Real((v * 1000.0).round() / 1000.0)
}
/// The current fill and stroke spaces (None = a device space set by g/rg/k, which the
/// converted operator sets again), with the q/Q stack: what one content stream leaves for the
/// next on the same page.
#[derive(Clone, Debug, Default)]
struct State {
fill: Option<Space>,
stroke: Option<Space>,
stack: Vec<(Option<Space>, Option<Space>)>,
}
/// Rewrite one content stream's colour operators, starting from `state` and leaving in it the
/// state at the stream's end. `cs_res` is its `/ColorSpace` resource dictionary. Returns the
/// new bytes when anything changed, and how many operators.
fn rewrite(data: &[u8], state: &mut State, doc: &Document, cs_res: &Dict, settings: &ConvertColours) -> (Option<Vec<u8>>, usize) {
let mut ops: Vec<Op> = parse(data).ops;
let mut count = 0usize;
let mut out: Vec<Op> = Vec::with_capacity(ops.len());
let State { fill, stroke, stack } = state;
let numbers = |op: &Op| -> Vec<f64> { op.operands.iter().filter_map(Object::as_f64).collect() };
let convert = |c: [f64; 3], is_stroke: bool| -> Op {
Op::new(op_for(settings.target, is_stroke), components(settings.target, c, settings.preserve_black).into_iter().map(r3).collect())
};
for op in ops.drain(..) {
match op.op.as_slice() {
b"q" => {
stack.push((fill.clone(), stroke.clone()));
if stack.len() > 512 {
stack.remove(0);
}
out.push(op);
}
b"Q" => {
if let Some((f, s)) = stack.pop() {
*fill = f;
*stroke = s;
}
out.push(op);
}
b"cs" | b"CS" => {
let is_stroke = op.op.as_slice() == b"CS";
let space = op.operands.first().map(|o| space_of(doc, o, cs_res, 0)).unwrap_or(Space::Gray);
let pattern = space == Space::Pattern;
if is_stroke {
*stroke = Some(space);
} else {
*fill = Some(space);
}
if pattern {
// Patterns stay what they are (their own content is not converted here).
out.push(op);
} else {
// The space's initial colour, now in the target: black (a tint at full,
// DeviceCMYK's 0 0 0 1, ISO 32000-1 8.6.5.5).
let space = if is_stroke { stroke.clone() } else { fill.clone() }.unwrap_or(Space::Gray);
let initial = match space {
Space::Tint { .. } => rgb(&space, &[1.0; 8]),
Space::Cmyk if op.operands.first().is_some_and(|o| device_cmyk(doc, o, cs_res)) => rgb(&space, &[0.0, 0.0, 0.0, 1.0]),
_ => rgb(&space, &[]),
};
out.push(convert(initial, is_stroke));
count += 1;
}
}
b"g" | b"rg" | b"k" | b"G" | b"RG" | b"K" => {
let is_stroke = op.op[0].is_ascii_uppercase();
let space = match op.op.to_ascii_lowercase().as_slice() {
b"g" => Space::Gray,
b"rg" => Space::Rgb,
_ => Space::Cmyk,
};
let already = matches!(
(settings.target, &space),
(ColourTarget::Gray, Space::Gray) | (ColourTarget::Rgb, Space::Rgb) | (ColourTarget::Cmyk, Space::Cmyk)
);
if is_stroke {
*stroke = None;
} else {
*fill = None;
}
if already {
out.push(op);
} else {
let c = rgb(&space, &numbers(&op));
out.push(convert(c, is_stroke));
count += 1;
}
}
b"sc" | b"scn" | b"SC" | b"SCN" => {
let is_stroke = op.op[0].is_ascii_uppercase();
let space = if is_stroke { stroke.clone() } else { fill.clone() };
match space {
Some(Space::Pattern) | None if op.operands.iter().any(|o| matches!(o, Object::Name(_))) => out.push(op),
None => {
// sc after g/rg/k: the device space those set; read the numbers by count.
let n = numbers(&op);
let space = match n.len() {
1 => Space::Gray,
4 => Space::Cmyk,
_ => Space::Rgb,
};
out.push(convert(rgb(&space, &n), is_stroke));
count += 1;
}
Some(Space::Pattern) => out.push(op),
Some(space) => {
out.push(convert(rgb(&space, &numbers(&op)), is_stroke));
count += 1;
}
}
}
_ => out.push(op),
}
}
((count > 0).then(|| serialize_ops(&out)), count)
}
/// Whether `space`'s colours are `target`'s (an indexed space's are its base's).
fn in_target(space: &Space, target: ColourTarget) -> bool {
match (space, target) {
(Space::Gray, ColourTarget::Gray) | (Space::Rgb, ColourTarget::Rgb) | (Space::Cmyk, ColourTarget::Cmyk) => true,
(Space::Indexed { base, .. }, _) => in_target(base, target),
_ => false,
}
}
/// Whether the picture `s` is outside `target`: a stencil mask has no colours of its own (it
/// paints with the fill colour, which is converted).
fn needs_converting(doc: &Document, s: &Stream, target: ColourTarget) -> bool {
let stencil = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true)));
!stencil && !s.dict.get(b"ColorSpace").is_some_and(|cs| in_target(&space_of(doc, cs, &Dict::default(), 0), target))
}
fn stream_of(doc: &Document, r: ObjRef) -> Option<Stream> {
match &*doc.get(r) {
Object::Stream(s) => Some(s.clone()),
_ => None,
}
}
fn cs_resources(doc: &Document, resources: Option<&Dict>) -> Dict {
resources.and_then(|r| r.get(b"ColorSpace")).map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()).unwrap_or_default()
}
/// What a run keeps across pages: each XObject already handled, and the object that now
/// stands for it (itself, or the converted copy made because a page not being converted draws
/// it too); and the XObjects those other pages draw.
struct Run {
done: HashMap<ObjRef, ObjRef>,
shared: HashSet<ObjRef>,
}
/// The XObjects (forms and images, and those the forms draw), and appearance streams, that
/// the pages not in `pages` draw: converting one of those in place would change those pages.
fn drawn_elsewhere(doc: &Document, pages: &[usize]) -> HashSet<ObjRef> {
let mut seen: HashSet<ObjRef> = HashSet::new();
let mut todo: Vec<Object> = Vec::new();
let xobjects_of = |res: Option<&Object>, todo: &mut Vec<Object>| {
if let Some(xo) = res
.map(|r| doc.resolve(r))
.and_then(|r| r.as_dict().and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x)))
.and_then(|x| x.as_dict().cloned())
{
todo.extend(xo.iter().map(|(_, o)| o.clone()));
}
};
for (i, page) in page_list(doc).into_iter().enumerate() {
if pages.contains(&i) {
continue;
}
xobjects_of(page.dict.get(b"Resources"), &mut todo);
let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
for a in annots {
let Some(ap) = doc.resolve(&a).as_dict().and_then(|d| d.get(b"AP").cloned()) else { continue };
let Some(ap) = doc.resolve(&ap).as_dict().cloned() else { continue };
for (_, v) in ap.iter() {
match &*doc.resolve(v) {
Object::Dict(states) => todo.extend(states.iter().map(|(_, s)| s.clone())),
_ => todo.push(v.clone()),
}
}
}
}
while let Some(o) = todo.pop() {
let Some(r) = o.as_ref() else { continue };
if !seen.insert(r) {
continue;
}
let res = match &*doc.get(r) {
Object::Stream(s) => s.dict.get(b"Resources").cloned(),
_ => None,
};
xobjects_of(res.as_ref(), &mut todo);
}
seen
}
/// Store a converted XObject: in place, or, when another page draws `r` too, as a new object
/// the caller points to instead. Returns the object that now stands for `r`.
fn put(doc: &mut Document, run: &mut Run, r: ObjRef, obj: Object) -> ObjRef {
let to = if run.shared.contains(&r) {
doc.add(obj)
} else {
doc.set(r, obj);
r
};
run.done.insert(r, to);
to
}
/// Convert the XObjects of `resources`: forms rewritten (and their own XObjects after them),
/// images through `recolour`. Each object is converted once; one that a page not being
/// converted draws is converted as a copy, and the returned `/XObject` dictionary, when there
/// is one, points at the copies (the caller puts it in `resources`' place). When `may_copy` is
/// false the caller cannot do that, so such objects are left alone.
fn convert_xobjects(
doc: &mut Document,
resources: Option<&Dict>,
settings: &ConvertColours,
recolour: &mut ImageRecolour<'_>,
run: &mut Run,
result: &mut Converted,
may_copy: bool,
) -> Option<Dict> {
let xo = resources.and_then(|r| r.get(b"XObject")).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?;
let mut repointed = xo.clone();
let mut changed = false;
for (name, o) in xo.iter() {
let Some(r) = o.as_ref() else { continue };
if let Some(&to) = run.done.get(&r) {
if to != r {
repointed.set(name.clone(), Object::Ref(to));
changed = true;
}
continue;
}
if !may_copy && run.shared.contains(&r) {
continue;
}
// Marked before converting, so a form that draws itself ends.
run.done.insert(r, r);
let Some(s) = stream_of(doc, r) else { continue };
let stored = match s.dict.name(b"Subtype") {
Some(b"Form") => {
let res = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned());
let cs = cs_resources(doc, res.as_ref());
let (new, n) = match s.decoded() {
Ok(data) => rewrite(&data, &mut State::default(), doc, &cs, settings),
Err(_) => (None, 0),
};
let inner = convert_xobjects(doc, res.as_ref(), settings, recolour, run, result, true);
if new.is_none() && inner.is_none() {
None
} else {
let mut form = s.clone();
if let Some(xo) = inner {
let mut res = res.unwrap_or_default();
res.set(b"XObject".to_vec(), Object::Dict(xo));
form.dict.set(b"Resources".to_vec(), Object::Dict(res));
}
if let Some(bytes) = new {
let mut dict = form.dict;
for k in [&b"Length"[..], b"Filter", b"DecodeParms"] {
dict.remove(k);
}
form = Stream::flate(dict, &bytes);
result.colours += n;
}
Some(put(doc, run, r, Object::Stream(form)))
}
}
Some(b"Image") if settings.images => match recolour(doc, &s, settings.target) {
Some(new) => {
result.images += 1;
Some(put(doc, run, r, Object::Stream(new)))
}
None => {
result.images_left += usize::from(needs_converting(doc, &s, settings.target));
None
}
},
_ => None,
};
if let Some(to) = stored
&& to != r
{
repointed.set(name.clone(), Object::Ref(to));
changed = true;
}
}
changed.then_some(repointed)
}
/// Convert the colours on `pages` (0-based). An object that pages not in `pages` also draw is
/// converted as a copy for these pages, so the others keep their colours.
pub fn convert_colours(
doc: &mut Document,
pages: &[usize],
settings: &ConvertColours,
recolour: &mut ImageRecolour<'_>,
) -> Result<Converted, EditError> {
let all = page_list(doc);
check(pages, all.len())?;
let mut result = Converted::default();
let mut run = Run { done: HashMap::new(), shared: drawn_elsewhere(doc, pages) };
for &i in pages {
let Some(page) = page_list(doc).get(i).cloned() else { continue };
let resources = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
let cs = cs_resources(doc, resources.as_ref());
let before = (result.colours, result.images);
// Each content stream on its own (state carries across them); a page with a stream
// that cannot be read keeps its content as it is.
if let Some(streams) = PageStreams::read(doc, &page.dict) {
let mut state = State::default();
let mut new: Vec<Option<Vec<u8>>> = Vec::with_capacity(streams.data.len());
for data in &streams.data {
let (bytes, n) = rewrite(data, &mut state, doc, &cs, settings);
result.colours += n;
new.push(bytes);
}
streams.write(doc, page.obj, new)?;
}
if let Some(xo) = convert_xobjects(doc, resources.as_ref(), settings, recolour, &mut run, &mut result, true) {
// This page's own resources point at the copies; a resource dictionary other pages
// share is not changed.
let mut res = resources.clone().unwrap_or_default();
res.set(b"XObject".to_vec(), Object::Dict(xo));
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
}
if settings.annotations {
let annots: Vec<ObjRef> = page
.dict
.get(b"Annots")
.map(|a| doc.resolve(a))
.and_then(|a| a.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|a| a.as_ref())
.collect();
for a in annots {
let Some(ap) =
doc.get(a).as_dict().and_then(|d| d.get(b"AP").cloned()).map(|ap| doc.resolve(&ap)).and_then(|ap| ap.as_dict().cloned())
else {
continue;
};
// Every appearance stream (and the states of a /N dictionary) as a form.
let mut streams: Vec<ObjRef> = Vec::new();
for (_, v) in ap.iter() {
match &*doc.resolve(v) {
Object::Stream(_) => streams.extend(v.as_ref()),
Object::Dict(states) => streams.extend(states.iter().filter_map(|(_, s)| s.as_ref())),
_ => {}
}
}
let mut fake = Dict::new();
let mut xo = Dict::new();
for (k, r) in streams.iter().enumerate() {
xo.set(format!("A{k}").into_bytes(), Object::Ref(*r));
}
fake.set(b"XObject".to_vec(), Object::Dict(xo));
// An appearance stream other pages draw is left alone (there is no resource
// dictionary here to point at a copy).
convert_xobjects(doc, Some(&fake), settings, recolour, &mut run, &mut result, false);
}
}
if (result.colours, result.images) != before {
result.pages += 1;
}
}
Ok(result)
}
+9
View File
@@ -61,6 +61,15 @@ impl PageImage {
self.clips.iter().any(|clip| clip.app_crop)
}
/// The editing frame, respecting explicit PDF/editor crops but not the page boundary.
/// Selected handles remain reachable outside the page; body hits still use `contains`.
pub fn frame_rect(&self) -> Option<[f64; 4]> {
self.clips.iter().try_fold(self.rect, |rect, clip| {
let b = clip.path.bounding_box();
intersection(rect, [b.x0, b.y0, b.x1, b.y1])
})
}
/// Whether a user-space point belongs to the placed artwork and every active clip.
pub fn contains(&self, point: [f64; 2]) -> bool {
if !point.iter().all(|v| v.is_finite()) {
+5
View File
@@ -64,8 +64,13 @@ pub use text::{
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping,
clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
};
pub mod printer_marks;
pub use printer_marks::{PrinterMarks, add_printer_marks};
mod colour;
pub mod convert_colours;
pub mod hairlines;
mod page_streams;
pub use convert_colours::{ColourTarget, ConvertColours, Converted as ColoursConverted, ImageRecolour, convert_colours};
pub use hairlines::{Fixed as HairlinesFixed, Hairlines, fix_hairlines};
pub mod added;
pub use added::{
@@ -121,6 +121,7 @@ fn apply_replacement(doc: &mut Document, kind: MarkKind, source: MarkSource, rep
MarkKind::Background => {
add_background(doc, SELECTED, &Background { color: [0.1, 0.3, 0.7], replace_backdrop: true, ..Default::default() }, replace)
}
MarkKind::PrinterMarks => Ok(()),
}
}
@@ -172,6 +173,7 @@ fn replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_re
MarkKind::HeaderFooter => "(New header)".to_string(),
MarkKind::Watermark => format!("/PCPic{} Do", source.xobject.num),
MarkKind::Background => "0.1 0.3 0.7 rg".to_string(),
MarkKind::PrinterMarks => String::new(),
};
apply_replacement(&mut doc, kind, source, true).unwrap();
let doc = reopen(&doc);
+3
View File
@@ -77,6 +77,8 @@ pub enum MarkKind {
HeaderFooter,
Watermark,
Background,
/// Printer marks: trim, bleed, registration, colour bars, page information.
PrinterMarks,
}
impl MarkKind {
@@ -85,6 +87,7 @@ impl MarkKind {
MarkKind::HeaderFooter => "HeaderFooter",
MarkKind::Watermark => "Watermark",
MarkKind::Background => "Background",
MarkKind::PrinterMarks => "PrinterMarks",
}
}
}
+340
View File
@@ -0,0 +1,340 @@
//! Printer marks (Acrobat's Print production ▸ Add Printer Marks): trim and
//! bleed marks, registration targets, colour bars and page information drawn outside the trim
//! box, as a page mark like a header, so they can be removed or replaced. The page is widened
//! when asked and the marks would otherwise fall outside it; the trim box stays where it is.
//!
//! Lines, targets and the information line are in registration colour, the `/All` Separation
//! (ISO 32000-2, 8.6.6.4), so they print on every plate, spot plates too; viewers show its
//! four-ink alternate. The colour bars are process inks and stay DeviceCMYK.
use pdfcraft_cos::{Dict, Document, Object};
use pdfcraft_fonts::{literal, win_ansi};
use crate::marks::{MarkKind, n};
use crate::place::{END, add_resources, begin, check, page_list, place};
use crate::{EditError, remove_marks};
/// Which marks, and how heavy.
#[derive(Clone, Debug, PartialEq)]
pub struct PrinterMarks {
pub trim: bool,
pub bleed: bool,
pub registration: bool,
pub color_bars: bool,
pub page_info: bool,
/// Line weight in points (Acrobat: 0.25, 0.5, 1).
pub line_weight: f64,
/// Widen the page by this many points on each side (at least what the marks need) when
/// they would fall outside it; `None` leaves the page and clips the marks.
pub make_room: Option<f64>,
/// What the page information line says before the page number (the file's name).
pub label: String,
/// The date on the page information line.
pub date: String,
}
impl Default for PrinterMarks {
fn default() -> Self {
Self {
trim: true,
bleed: true,
registration: true,
color_bars: true,
page_info: true,
line_weight: 0.25,
make_room: Some(36.0),
label: String::new(),
date: String::new(),
}
}
}
/// How far a mark reaches beyond the trim box: a gap, then the mark itself.
const GAP: f64 = 6.0;
const LENGTH: f64 = 18.0;
/// Registration targets sit this far beyond the trim box, centred on each side.
const TARGET_OFFSET: f64 = 16.0;
const TARGET_RADIUS: f64 = 4.0;
const BAR: f64 = 10.0;
const INFO_SIZE: f64 = 7.0;
/// The colour space resource holding registration colour.
const REGISTRATION: &str = "PCReg";
/// The largest page side PDF viewers take (Annex C of ISO 32000-1 lists it as Acrobat's limit).
const MAX_PAGE_SIDE: f64 = 14_400.0;
/// `[/Separation /All /DeviceCMYK <tint>]`: full tint is all four process inks.
fn registration() -> Object {
let reals = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect());
let mut tint = Dict::new();
tint.set(b"FunctionType".to_vec(), Object::Int(2));
tint.set(b"Domain".to_vec(), reals(&[0.0, 1.0]));
tint.set(b"C0".to_vec(), reals(&[0.0; 4]));
tint.set(b"C1".to_vec(), reals(&[1.0; 4]));
tint.set(b"N".to_vec(), Object::Int(1));
Object::Array(vec![Object::name("Separation"), Object::name("All"), Object::name("DeviceCMYK"), Object::Dict(tint)])
}
impl PrinterMarks {
/// The line weights a run accepts, in points.
pub const WEIGHTS: std::ops::RangeInclusive<f64> = 0.1..=4.0;
/// The most a run widens a page by on each side at the caller's asking, in points.
pub const MAX_ROOM: f64 = 288.0;
fn any(&self) -> bool {
self.trim || self.bleed || self.registration || self.color_bars || self.page_info
}
/// How far beyond the trim box the chosen marks reach.
pub fn reach(&self) -> f64 {
let mut r: f64 = 0.0;
if self.trim || self.bleed {
r = r.max(GAP + LENGTH);
}
if self.registration {
r = r.max(TARGET_OFFSET + TARGET_RADIUS + 2.0);
}
if self.color_bars || self.page_info {
r = r.max(GAP + BAR + 6.0);
}
r
}
/// The marks' content for a page whose trim box is `trim` and bleed box `bleed` (user
/// space), `page` of `count` (1-based).
pub fn content(&self, trim: [f64; 4], bleed: [f64; 4], page: usize, count: usize) -> String {
let w = self.line_weight.clamp(*Self::WEIGHTS.start(), *Self::WEIGHTS.end());
// Registration colour: every plate.
let mut c = format!("/{REGISTRATION} CS 1 SCN /{REGISTRATION} cs 1 scn {} w 0 J\n", n(w));
let [x0, y0, x1, y1] = trim;
let line = |c: &mut String, ax: f64, ay: f64, bx: f64, by: f64| {
c.push_str(&format!("{} {} m {} {} l S\n", n(ax), n(ay), n(bx), n(by)));
};
// Crop (trim) marks: at each corner, outside the box, along both edges.
let corner_marks = |c: &mut String, b: [f64; 4]| {
let [bx0, by0, bx1, by1] = b;
for (x, dir) in [(bx0, -1.0), (bx1, 1.0)] {
for y in [by0, by1] {
// Vertical mark beyond the top or bottom edge.
let (ya, yb) = if y == by0 { (y - GAP, y - GAP - LENGTH) } else { (y + GAP, y + GAP + LENGTH) };
line(c, x, ya, x, yb);
// Horizontal mark beyond the left or right edge.
line(c, x + dir * GAP, y, x + dir * (GAP + LENGTH), y);
}
}
};
if self.trim {
corner_marks(&mut c, trim);
}
if self.bleed && bleed != trim {
c.push_str(&format!("[{} {}] 0 d\n", n(w * 4.0), n(w * 4.0)));
corner_marks(&mut c, bleed);
c.push_str("[] 0 d\n");
}
if self.registration {
let r = TARGET_RADIUS;
let k = 0.5523 * r;
let target = |c: &mut String, cx: f64, cy: f64| {
c.push_str(&format!(
"{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c S\n",
n(cx + r),
n(cy),
n(cx + r),
n(cy + k),
n(cx + k),
n(cy + r),
n(cx),
n(cy + r),
n(cx - k),
n(cy + r),
n(cx - r),
n(cy + k),
n(cx - r),
n(cy),
n(cx - r),
n(cy - k),
n(cx - k),
n(cy - r),
n(cx),
n(cy - r),
n(cx + k),
n(cy - r),
n(cx + r),
n(cy - k),
n(cx + r),
n(cy)
));
line(c, cx - r * 1.6, cy, cx + r * 1.6, cy);
line(c, cx, cy - r * 1.6, cx, cy + r * 1.6);
};
let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
target(&mut c, mx, y1 + TARGET_OFFSET);
target(&mut c, mx, y0 - TARGET_OFFSET);
target(&mut c, x0 - TARGET_OFFSET, my);
target(&mut c, x1 + TARGET_OFFSET, my);
}
if self.color_bars {
// Above the top edge, from the left: C, M, Y, K at 100%, then 75, 50, 25% K.
let y = y1 + GAP + 2.0;
let x = x0 + LENGTH + GAP + 12.0;
let swatches: [(f64, f64, f64, f64); 7] = [
(1.0, 0.0, 0.0, 0.0),
(0.0, 1.0, 0.0, 0.0),
(0.0, 0.0, 1.0, 0.0),
(0.0, 0.0, 0.0, 1.0),
(0.0, 0.0, 0.0, 0.75),
(0.0, 0.0, 0.0, 0.5),
(0.0, 0.0, 0.0, 0.25),
];
for (i, (cy, m, yl, k)) in swatches.iter().enumerate() {
c.push_str(&format!("{} {} {} {} k {} {} {} {} re f\n", n(*cy), n(*m), n(*yl), n(*k), n(x + i as f64 * BAR), n(y), n(BAR), n(BAR)));
}
c.push_str(&format!("/{REGISTRATION} cs 1 scn\n"));
}
if self.page_info {
let mut info = String::new();
if !self.label.trim().is_empty() {
info.push_str(self.label.trim());
info.push_str(" ");
}
info.push_str(&format!("{page} of {count}"));
if !self.date.trim().is_empty() {
info.push_str(" ");
info.push_str(self.date.trim());
}
let info: String = info.chars().filter(|ch| pdfcraft_fonts::win_ansi_encodable(*ch)).collect();
c.push_str(&format!("BT /PCHelv {} Tf 1 0 0 1 {} {} Tm ", n(INFO_SIZE), n(x0 + LENGTH + GAP + 12.0), n(y0 - GAP - BAR)));
c.push_str(&ascii_literal(&literal(&win_ansi(&info))));
c.push_str(" Tj ET\n");
}
c
}
}
/// A PDF literal string made of WinAnsi bytes, as ASCII text: bytes from 0x80 up are written as
/// octal escapes, so they reach the content stream unchanged instead of being replaced when the
/// bytes are turned into a `String`.
fn ascii_literal(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len());
for &b in bytes {
if b < 0x80 {
out.push(char::from(b));
} else {
out.push_str(&format!("\\{b:03o}"));
}
}
out
}
/// How far the chosen marks reach beyond the trim box on each side (left, bottom, right, top):
/// `reach`, or more where the bleed marks, which start at the bleed box, go further out.
fn side_reach(marks: &PrinterMarks, trim: [f64; 4], bleed: [f64; 4]) -> [f64; 4] {
let base = marks.reach();
if !(marks.bleed && bleed != trim) {
return [base; 4];
}
let beyond = |bleed_edge: f64, trim_edge: f64| base.max((trim_edge - bleed_edge).abs() + GAP + LENGTH);
[beyond(bleed[0], trim[0]), beyond(bleed[1], trim[1]), beyond(bleed[2], trim[2]), beyond(bleed[3], trim[3])]
}
/// Add printer marks to `pages` (0-based). With `replace`, marks already there are removed
/// first. Returns the pages that were widened (media or crop box) to make room.
pub fn add_printer_marks(doc: &mut Document, pages: &[usize], marks: &PrinterMarks, replace: bool) -> Result<Vec<usize>, EditError> {
let all = page_list(doc);
check(pages, all.len())?;
if !marks.any() {
return Err(EditError::Invalid("choose at least one kind of mark".into()));
}
if !PrinterMarks::WEIGHTS.contains(&marks.line_weight) {
let (low, high) = (PrinterMarks::WEIGHTS.start(), PrinterMarks::WEIGHTS.end());
return Err(EditError::Invalid(format!("the line weight must be from {low} to {high} points")));
}
if marks.make_room.is_some_and(|room| !(0.0..=PrinterMarks::MAX_ROOM).contains(&room)) {
return Err(EditError::Invalid(format!("the room to make must be from 0 to {} points", PrinterMarks::MAX_ROOM)));
}
if replace {
remove_marks(doc, pages, MarkKind::PrinterMarks)?;
}
let count = all.len();
let mut widened = Vec::new();
for &i in pages {
let [media, crop, bleed, trim, art] = pdfcraft_organize_boxes(doc, i)?;
let side = side_reach(marks, trim, bleed);
let needed = [trim[0] - side[0], trim[1] - side[1], trim[2] + side[2], trim[3] + side[3]];
let within = |b: [f64; 4]| b[0] <= needed[0] && b[1] <= needed[1] && b[2] >= needed[2] && b[3] >= needed[3];
// The crop box is what shows, so the marks need room inside it, not only inside the media box.
if !within(crop)
&& let Some(room) = marks.make_room
{
let new_media = if within(media) {
None
} else {
let room = |s: f64| room.max(s);
Some([
media[0].min(trim[0] - room(side[0])),
media[1].min(trim[1] - room(side[1])),
media[2].max(trim[2] + room(side[2])),
media[3].max(trim[3] + room(side[3])),
])
};
if new_media.is_some_and(|m| m[2] - m[0] > MAX_PAGE_SIDE || m[3] - m[1] > MAX_PAGE_SIDE) {
return Err(EditError::Invalid(format!("page {} would be wider than the {MAX_PAGE_SIDE} points a PDF page can be", i + 1)));
}
// Without a wider media box, only the crop box grows, to the marks and no further than the media box.
let new_crop = match new_media {
Some(m) => m,
None => [
crop[0].min(needed[0]).max(media[0]),
crop[1].min(needed[1]).max(media[1]),
crop[2].max(needed[2]).min(media[2]),
crop[3].max(needed[3]).min(media[3]),
],
};
let rect = |r: [f64; 4]| Object::Array(r.iter().map(|v| Object::Real(*v)).collect());
let page = page_list(doc)[i].clone();
doc.update_dict(page.obj, |d| {
if let Some(m) = new_media {
d.set(b"MediaBox".to_vec(), rect(m));
}
// The crop box shows the marks too. The trim, bleed and art boxes default to the
// crop box and are clipped to the media box, so each is written out as it was in
// effect, or it would move with the wider boxes and a second run would widen the
// page again.
d.set(b"CropBox".to_vec(), rect(new_crop));
for (key, value) in [(&b"BleedBox"[..], bleed), (&b"TrimBox"[..], trim), (&b"ArtBox"[..], art)] {
d.set(key.to_vec(), rect(value));
}
})?;
widened.push(i);
}
let page = page_list(doc)[i].clone();
let content = marks.content(trim, bleed, i + 1, count);
let body = format!("{}{}{}", begin(MarkKind::PrinterMarks, "PrinterMarks", [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), content, END);
add_resources(doc, &page, None, Some(body.as_bytes()))?;
let page = page_list(doc)[i].clone();
// add_resources gave the page resources of its own.
let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut spaces = res.get(b"ColorSpace").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()).unwrap_or_default();
spaces.set(REGISTRATION.as_bytes().to_vec(), registration());
res.set(b"ColorSpace".to_vec(), Object::Dict(spaces));
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
let page = page_list(doc)[i].clone();
place(doc, &page, MarkKind::PrinterMarks, body.into_bytes(), false)?;
}
Ok(widened)
}
/// A page's boxes: media, crop, bleed, trim, art (each inherited or falling back as the
/// standard says), without depending on the organize crate.
fn pdfcraft_organize_boxes(doc: &Document, page: usize) -> Result<[[f64; 4]; 5], EditError> {
let p = page_list(doc).get(page).cloned().ok_or_else(|| EditError::Invalid(format!("page {} does not exist", page + 1)))?;
let rect = |k: &[u8]| -> Option<[f64; 4]> {
let a = p.dict.get(k).map(|o| doc.resolve(o))?;
let v: Vec<f64> = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
(v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])])
};
let media = rect(b"MediaBox").unwrap_or([0.0, 0.0, 612.0, 792.0]);
let clip = |r: [f64; 4]| [r[0].max(media[0]), r[1].max(media[1]), r[2].min(media[2]), r[3].min(media[3])];
let crop = rect(b"CropBox").map(clip).unwrap_or(media);
let (bleed, trim, art) = (rect(b"BleedBox").map(clip), rect(b"TrimBox").map(clip), rect(b"ArtBox").map(clip));
Ok([media, crop, bleed.unwrap_or(crop), trim.unwrap_or(crop), art.unwrap_or(crop)])
}
+4
View File
@@ -26,6 +26,10 @@ pub fn page_rules(doc: &Document, page: usize) -> Result<Vec<[f64; 4]>, EditErro
let pages = pdfcraft_model::pages(doc);
let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?;
let streams = crate::text::content_streams(doc, &p.dict)?;
// Unread content can change the graphics state used by every later stream.
if streams.iter().any(|stream| stream.read != crate::text::Read::Whole) {
return Err(EditError::Invalid("page content could not be completely decoded; cannot extract its ruled lines safely".into()));
}
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
+623 -14
View File
@@ -2109,6 +2109,229 @@ fn japanese_fallback_reports_the_face_after_save_and_reopen() {
}
}
#[test]
#[ignore = "manual mixed-script rewrite timing probe; run with --ignored --nocapture"]
fn mixed_script_paragraph_rewrite_timing_probe() {
assert!(pdfcraft_fonts::document_japanese_font().is_some(), "the timing probe requires the release-pinned craft-fonts input");
for (repeats, width) in [(32, 480.0), (512, 480.0), (2048, 480.0), (512, 14_400.0)] {
let draft = "The quick brown fox шалом ".repeat(repeats);
let mut samples = Vec::new();
for _ in 0..3 {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (The quick brown fox) Tj ET");
let style = text::BlockStyle {
width: Some(width),
align: Some(added::Align::Center),
underline: Some(true),
char_spacing: Some(1.25),
scale: Some(80.0),
..Default::default()
};
let start = std::time::Instant::now();
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap();
samples.push(start.elapsed().as_micros());
assert!(text::text_lines(&doc, 0).unwrap().iter().any(|line| line.base_font == "Helvetica"));
std::hint::black_box(doc);
}
samples.sort_unstable();
eprintln!("mixed-script-rewrite bytes={} width={width} median_us={} samples_us={samples:?}", draft.len(), samples[1]);
}
}
#[test]
fn mixed_script_source_measurements_are_bounded_by_distinct_characters() {
if without_craft_fonts("mixed_script_source_measurements_are_bounded_by_distinct_characters") {
return;
}
for resource in ["F1", "F2"] {
for repeats in [1, 256] {
for width in [80.0, 14_400.0] {
let draft = "ab шалом ab ".repeat(repeats);
let distinct = draft.chars().collect::<std::collections::HashSet<_>>().len();
let mut doc = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET"));
let style = text::BlockStyle { width: Some(width), align: Some(added::Align::Justify), underline: Some(true), ..Default::default() };
text::take_source_character_measurements();
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap();
let measured = text::take_source_character_measurements();
assert_eq!(measured, distinct, "{resource}, {repeats} repeats, width {width}: measure each distinct source candidate once");
}
}
}
}
#[test]
fn mixed_script_cached_widths_follow_each_rewrite_state() {
if without_craft_fonts("mixed_script_cached_widths_follow_each_rewrite_state") {
return;
}
let draft = "ab шалом ab шалом ab шалом ab";
for (resource, size, spacing, scale) in [("F2", 12.0, 0.0, 100.0), ("F1", 18.0, 1.25, 80.0), ("F2", 18.0, 1.25, 80.0), ("F1", 12.0, 0.0, 100.0)] {
let mut doc = text_page(&format!("BT /{resource} 12 Tf 5 Tw 72 700 Td (ab) Tj ET"));
let original_font = doc.get(pdfcraft_cos::ObjRef::new(if resource == "F1" { 5 } else { 6 }, 0)).as_ref().clone();
let style = text::BlockStyle {
size: Some(size),
width: Some(120.0),
align: Some(added::Align::Center),
underline: Some(true),
char_spacing: Some(spacing),
scale: Some(scale),
..Default::default()
};
text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap();
let doc = reopen(&doc);
assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(if resource == "F1" { 5 } else { 6 }, 0)).as_ref(), &original_font);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text.split_whitespace().collect::<Vec<_>>(), draft.split_whitespace().collect::<Vec<_>>());
let page = pdfcraft_model::pages(&doc).remove(0);
let fonts = doc.dict(doc.dict(page.dict.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap();
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
let underlines: Vec<_> = ops.iter().filter(|op| op.is("m") || op.is("l")).collect();
let rows: Vec<_> = lines.chunk_by(|a, b| (a.origin_baseline() - b.origin_baseline()).abs() < 0.001).collect();
assert!(rows.len() > 1, "the cache must also be used while wrapping");
assert_eq!(underlines.len(), rows.len() * 2);
for (row, underline) in rows.into_iter().zip(underlines.as_chunks::<2>().0) {
// Recompute from the saved fonts, independently of the rewrite's cache.
let widths: Vec<_> = row
.iter()
.map(|line| {
if line.text.chars().any(|ch| ch.is_ascii_alphabetic()) {
assert_eq!(line.font, resource);
}
let m = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, &doc.dict(fonts.get(line.font.as_bytes()).unwrap()).unwrap());
let bytes = m.encode(&line.text).unwrap_or_else(|| panic!("saved font {} cannot encode {line:?}", m.base_font));
m.codes(&bytes).iter().map(|(code, _)| m.width(*code) * size + spacing).sum::<f64>() * scale / 100.0
})
.collect();
let total: f64 = widths.iter().sum();
let left = (120.0 - total).max(0.0) / 2.0;
let mut x = 72.0 + left;
for (line, advance) in row.iter().zip(widths) {
assert!((line.baseline()[0] - x).abs() < 0.02, "{resource}, size {size}: {line:?}, expected {x}");
x += advance;
}
assert!((underline[0].operands[0].as_f64().unwrap() - left).abs() < 0.02);
assert!((underline[1].operands[0].as_f64().unwrap() - left - total).abs() < 0.02);
}
}
}
#[test]
fn mixed_script_paragraph_keeps_the_source_font_without_an_explicit_line_break() {
if without_craft_fonts("mixed_script_paragraph_keeps_the_source_font_without_an_explicit_line_break") {
return;
}
let original = "The quick brown fox jumps over the lazy dog";
let ending = "and keeps running through the long field.";
let draft = format!("{original} шалом {ending}");
let mut doc = text_page(&format!("BT /F1 12 Tf 14.4 TL 72 700 Td ({original}) Tj T* ({ending}) Tj ET"));
let font = doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_ref().clone();
text::replace_block(&mut doc, 0, 0, &draft).unwrap();
let doc = reopen(&doc);
assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_ref(), &font);
let lines = text::text_lines(&doc, 0).unwrap();
assert!(lines.iter().any(|l| l.base_font == "Helvetica" && l.text.contains("The quick brown fox")), "{lines:#?}");
for line in &lines {
if line.text.chars().any(|ch| ch.is_ascii_alphabetic()) {
assert_eq!(line.base_font, "Helvetica", "Latin must retain its source font: {line:?}");
}
}
assert!(lines.iter().any(|l| l.text.contains("шалом") && l.base_font != "Helvetica"));
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text.split_whitespace().collect::<Vec<_>>(), draft.split_whitespace().collect::<Vec<_>>());
let fallback = page_font_programs(&doc).into_iter().filter(|(subtype, _)| subtype == b"Type3").count();
assert_eq!(fallback, 1, "the new letters share one fallback font");
}
#[test]
fn mixed_script_subset_spaces_remain_extractable_after_saving() {
if without_craft_fonts("mixed_script_subset_spaces_remain_extractable_after_saving") {
return;
}
for (resource, draft) in [("F2", "ab шалом ab"), ("F2", "шалом ab"), ("F2", "ab шалом"), ("F1", "ab шалом ab")] {
for align in [added::Align::Left, added::Align::Center, added::Align::Right] {
let mut doc = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET"));
let font_id = pdfcraft_cos::ObjRef::new(if resource == "F2" { 6 } else { 5 }, 0);
let original_font = doc.get(font_id).as_ref().clone();
let style =
text::BlockStyle { width: Some(300.0), align: Some(align), char_spacing: Some(1.25), scale: Some(80.0), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap();
for bytes in [write_full(&doc, &Default::default()).unwrap(), pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap()] {
let saved = Document::open(Arc::new(bytes)).unwrap();
let lines = text::text_lines(&saved, 0).unwrap();
let decoded: String = lines.iter().map(|line| line.text.as_str()).collect();
assert_eq!(decoded, draft, "{resource}: edge spaces of source runs must be real extractable glyphs");
assert_eq!(text::text_blocks(&saved, 0).unwrap()[0].text, draft);
assert_eq!(saved.get(font_id).as_ref(), &original_font);
for line in lines.iter().filter(|line| line.text.chars().any(|ch| ch.is_ascii_alphabetic())) {
assert_eq!(line.font, resource, "the Latin letters retain their source font: {line:?}");
}
}
}
}
}
/// Mixed-font fallback must preserve the refusal of unsupported per-row alignment.
#[test]
fn mixed_script_row_alignment_is_refused_without_changes() {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Latin text) Tj ET");
let before = write_full(&doc, &SaveOptions::default()).unwrap();
let style = text::BlockStyle { width: Some(300.0), aligns: vec![Some(added::Align::Center)], ..Default::default() };
let err = text::rewrite_block(&mut doc, 0, 0, Some("abcшаломdef"), &style).unwrap_err();
assert!(err.to_string().contains("selected-row alignment"), "{err}");
assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before);
}
#[test]
fn mixed_script_runs_use_source_and_fallback_advances_for_alignment() {
if without_craft_fonts("mixed_script_runs_use_source_and_fallback_advances_for_alignment") {
return;
}
for (resource, prefix, suffix) in [("F1", "abc", "def"), ("F2", "ab", "ab"), ("F1", "abc ", "def")] {
// Rewriting normalizes Tw; measuring a source space must use that same state.
let mut doc = text_page(&format!("BT /{resource} 12 Tf 5 Tw 72 700 Td ({prefix}) Tj ET"));
let draft = format!("{prefix}шалом{suffix}");
let style = text::BlockStyle {
width: Some(300.0),
align: Some(added::Align::Center),
char_spacing: Some(1.25),
scale: Some(80.0),
..Default::default()
};
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, draft);
assert_eq!((lines[0].font.as_str(), lines[2].font.as_str()), (resource, resource));
let page = pdfcraft_model::pages(&doc).remove(0);
let resources = doc.dict(page.dict.get(b"Resources").unwrap()).unwrap();
let fonts = doc.dict(resources.get(b"Font").unwrap()).unwrap();
let advances: Vec<f64> = lines
.iter()
.zip([prefix, "шалом", suffix])
.map(|(line, part)| {
let font = doc.dict(fonts.get(line.font.as_bytes()).unwrap()).unwrap();
let m = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, &font);
let bytes = m.encode(part).unwrap();
(m.codes(&bytes).iter().map(|(c, _)| m.width(*c) * 12.0).sum::<f64>() + part.chars().count() as f64 * 1.25) * 0.8
})
.collect();
let mut x = 72.0 + (300.0 - advances.iter().sum::<f64>()) / 2.0;
for (line, width) in lines.iter().zip(advances) {
assert!((line.baseline()[0] - x).abs() < 0.01, "{resource}: {line:?}, expected {x}");
x += width;
}
}
}
#[test]
fn mixed_script_paragraph_batches_only_missing_glyphs_and_refuses_atomically() {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Latin text) Tj ET");
// Cyrillic also uses bundled Inter in builds without craft-fonts.
text::rewrite_block_with_limit(&mut doc, 0, 0, Some("Latin шалом text"), &Default::default(), 2).unwrap();
assert_eq!(page_font_programs(&doc).iter().filter(|(subtype, _)| subtype == b"Type3").count(), 3);
let before = write_full(&doc, &Default::default()).unwrap();
assert!(text::replace_block(&mut doc, 0, 0, "Latin \u{FDFD} text").is_err());
assert_eq!(write_full(&doc, &Default::default()).unwrap(), before);
}
#[test]
fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() {
if without_craft_fonts("cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it") {
@@ -2127,7 +2350,33 @@ fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() {
} else {
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
}
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement);
let doc = reopen(&doc);
if paragraph {
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, replacement);
assert!(text::text_lines(&doc, 0).unwrap().iter().any(|l| l.text.contains("world") && l.base_font == "Times-Roman"));
} else {
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, replacement);
}
}
}
/// A subset missing spaces must still split source and fallback runs while aligning text.
#[test]
fn mixed_source_subset_without_space_keeps_fallback_runs_after_reopen() {
let draft = "a қ b a қ b";
for align in [added::Align::Left, added::Align::Center, added::Align::Justify] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let original = doc.get(pdfcraft_cos::ObjRef::new(6, 0)).as_ref().clone();
let style = text::BlockStyle { width: Some(36.0), align: Some(align), char_spacing: Some(1.25), scale: Some(80.0), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap();
let doc = reopen(&doc);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, draft);
assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(6, 0)).as_ref(), &original);
let lines = text::text_lines(&doc, 0).unwrap();
assert!(lines.iter().any(|line| line.text.contains('a') && line.font == "F2"));
assert!(lines.iter().any(|line| line.text.contains('b') && line.font == "F2"));
assert!(lines.iter().any(|line| line.text.contains('қ') && line.base_font == "Inter-Regular"));
assert!(lines.iter().any(|line| line.text.contains(' ') && line.base_font == "Inter-Regular"));
}
}
@@ -2144,9 +2393,22 @@ fn uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face() {
assert_eq!(edit.substituted.as_deref(), Some("Inter Type3"), "{replacement}: {edit:?}");
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("/PCLat"), "{replacement}: {content}");
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines[0].text, replacement);
assert_eq!(lines[0].base_font, "Inter-Regular", "the generated font reports the face it drew");
let reopened = reopen(&doc);
let lines = text::text_lines(&reopened, 0).unwrap();
if paragraph {
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
for ch in ['a', 'b'].into_iter().filter(|ch| replacement.contains(*ch)) {
assert!(lines.iter().any(|line| line.text.contains(ch) && line.base_font == "ABCDEF+Arial"), "{lines:?}");
}
} else {
assert_eq!(lines[0].text, replacement);
}
for ch in replacement.chars().filter(|ch| !ch.is_ascii()) {
assert!(
lines.iter().any(|line| line.text.contains(ch) && line.base_font == "Inter-Regular"),
"the generated Inter face draws {ch}: {lines:?}"
);
}
}
}
}
@@ -9399,6 +9661,113 @@ fn removing_marks_strips_wrappers_that_closed_open_state() {
assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]);
}
/// Printer marks: drawn outside the trim box as a removable page mark; the page
/// is widened to make room when asked and the trim box stays; running again replaces them.
#[test]
fn printer_marks_are_drawn_outside_the_trim_box_and_removable() {
use crate::printer_marks::PrinterMarks;
let mut doc = fixture();
let page_content_text = |doc: &Document, page: usize| String::from_utf8_lossy(&page_content_bytes(doc, page)).into_owned();
let page = page_list(&doc)[0].clone();
// A trim box inside a letter-size page, 36 pt in: room for the marks, bleed marks included
// (the bleed box is 9 pt out, so they reach 33 pt beyond the trim box), without widening.
doc.update_dict(page.obj, |d| {
d.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, 612.0, 792.0].iter().map(|v| Object::Real(*v)).collect()));
d.set(b"TrimBox".to_vec(), Object::Array([36.0, 36.0, 576.0, 756.0].iter().map(|v| Object::Real(*v)).collect()));
d.set(b"BleedBox".to_vec(), Object::Array([27.0, 27.0, 585.0, 765.0].iter().map(|v| Object::Real(*v)).collect()));
})
.unwrap();
let marks = PrinterMarks { label: "Quote 1042.pdf".into(), date: "2026-10-13".into(), ..PrinterMarks::default() };
let widened = add_printer_marks(&mut doc, &[0, 1], &marks, false).unwrap();
assert_eq!(widened, vec![1], "page 2 has no trim box inside its media box, so it was widened");
assert_eq!(marks_present(&doc), vec![MarkKind::PrinterMarks]);
let text = page_content_text(&doc, 0);
assert!(text.contains("/PCMark /PrinterMarks"), "{text}");
// A trim mark at the lower-left corner: vertical below the box, horizontal to its left.
assert!(text.contains("36 30 m 36 12 l S") && text.contains("30 36 m 12 36 l S"), "{text}");
assert!(text.contains("/PCReg CS 1 SCN /PCReg cs 1 scn"), "registration colour: {text}");
assert!(!text.contains("1 1 1 1 K"), "not four process inks alone: {text}");
assert!(text.contains("[1 1] 0 d") && text.contains("27 21 m 27 3 l S"), "dashed bleed marks: {text}");
assert!(text.contains("1 0 0 0 k") && text.contains("0 0 0 0.25 k"), "colour bars: {text}");
assert!(text.contains("(Quote 1042.pdf 1 of 3 2026-10-13) Tj"), "page information: {text}");
// Page 2: the media box grew by 36 pt a side around its (whole-page) trim box.
let p2 = page_list(&doc)[1].clone();
let media: Vec<f64> = p2.dict.get(b"MediaBox").and_then(|m| m.as_array()).unwrap().iter().filter_map(|v| v.as_f64()).collect();
assert_eq!(media, vec![-36.0, -36.0, 636.0, 836.0]);
// Again with replace: still one set of marks per page; without replace, two.
add_printer_marks(&mut doc, &[0], &marks, true).unwrap();
assert_eq!(page_content_text(&doc, 0).matches("/PCMark /PrinterMarks").count(), 1);
add_printer_marks(&mut doc, &[0], &marks, false).unwrap();
assert_eq!(page_content_text(&doc, 0).matches("/PCMark /PrinterMarks").count(), 2);
assert_eq!(remove_marks(&mut doc, &[0, 1], MarkKind::PrinterMarks).unwrap(), 3);
assert!(marks_present(&doc).is_empty());
// Nothing chosen, or a bad weight, is refused.
let none = PrinterMarks { trim: false, bleed: false, registration: false, color_bars: false, page_info: false, ..PrinterMarks::default() };
assert!(add_printer_marks(&mut doc, &[0], &none, false).is_err());
assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { line_weight: 0.0, ..PrinterMarks::default() }, false).is_err());
}
/// Convert colours: device, ICC-based and separation colours in page content
/// and forms become the target's operators; black is preserved; patterns stay; a second run
/// finds nothing.
#[test]
fn colours_are_converted_to_gray_rgb_and_cmyk() {
use crate::convert_colours::{ColourTarget, ConvertColours, convert_colours};
let mut doc = fixture();
let page = page_list(&doc)[0].clone();
// An ICCBased RGB space and a spot colour (Separation to CMYK, Type 2 tint).
let mut icc = Dict::new();
icc.set(b"N".to_vec(), Object::Int(3));
let icc_ref = doc.add(Object::Stream(Stream::flate(icc, b"not a real profile")));
let mut fun = Dict::new();
fun.set(b"FunctionType".to_vec(), Object::Int(2));
fun.set(b"Domain".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1)]));
fun.set(b"C0".to_vec(), Object::Array(vec![Object::Int(0); 4]));
fun.set(b"C1".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1), Object::Int(1), Object::Int(0)]));
fun.set(b"N".to_vec(), Object::Int(1));
let mut cs = Dict::new();
cs.set(b"CS0".to_vec(), Object::Array(vec![Object::name("ICCBased"), Object::Ref(icc_ref)]));
cs.set(b"Spot".to_vec(), Object::Array(vec![Object::name("Separation"), Object::name("Red"), Object::name("DeviceCMYK"), Object::Dict(fun)]));
let mut res = Dict::new();
res.set(b"ColorSpace".to_vec(), Object::Dict(cs));
let content = b"1 0 0 rg 10 10 50 50 re f 0 0 1 RG 0 0 m 100 100 l S /CS0 cs 0 1 0 scn 0 0 20 20 re f /Spot cs 1 scn 30 30 20 20 re f 0 g 60 60 10 10 re f 0.5 g 70 70 5 5 re f";
let c = doc.add(Object::Stream(Stream::flate(Dict::new(), content)));
doc.update_dict(page.obj, |d| {
d.set(b"Contents".to_vec(), Object::Ref(c));
d.set(b"Resources".to_vec(), Object::Dict(res));
})
.unwrap();
let mut no_images = |_: &Document, _: &Stream, _: ColourTarget| None;
let flat = |b: &[u8]| String::from_utf8_lossy(b).split_whitespace().collect::<Vec<_>>().join(" ");
// Grayscale.
let mut gray = doc.clone();
let done = convert_colours(&mut gray, &[0], &ConvertColours::default(), &mut no_images).unwrap();
assert_eq!((done.colours, done.pages), (6, 1), "{done:?}");
let t = flat(&page_content_bytes(&gray, 0));
assert!(t.contains("0.299 g 10 10 50 50 re f"), "red → its luma: {t}");
assert!(t.contains("0.114 G 0 0 m 100 100 l S"), "blue stroke: {t}");
assert!(t.contains("0 g 0 1 0 scn") || t.contains("0.587 g 0 0 20 20 re f"), "ICC green through its space: {t}");
assert!(t.contains("0.587 g 0 0 20 20 re f"), "{t}");
assert!(t.contains("0.299 g 30 30 20 20 re f"), "the spot's tint through its CMYK alternate (0 1 1 0 = red): {t}");
assert!(t.contains("0 g 60 60 10 10 re f") && t.contains("0.5 g 70 70 5 5 re f"), "gray stays gray: {t}");
assert!(!t.contains("/CS0 cs") && !t.contains("/Spot cs"), "named spaces are gone: {t}");
assert_eq!(convert_colours(&mut gray, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 0, "nothing left");
// CMYK with black preserved; without, black is still 100% K by the formula.
let mut cmyk = doc.clone();
let s = ConvertColours { target: ColourTarget::Cmyk, ..ConvertColours::default() };
convert_colours(&mut cmyk, &[0], &s, &mut no_images).unwrap();
let t = flat(&page_content_bytes(&cmyk, 0));
assert!(t.contains("0 1 1 0 k 10 10 50 50 re f"), "red: {t}");
assert!(t.contains("0 0 0 1 k 60 60 10 10 re f"), "black preserved: {t}");
assert!(t.contains("0 0 0 0.5 k 70 70 5 5 re f"), "gray 0.5 → 50% K: {t}");
// RGB: the spot becomes its red.
let mut rgbd = doc.clone();
convert_colours(&mut rgbd, &[0], &ConvertColours { target: ColourTarget::Rgb, ..ConvertColours::default() }, &mut no_images).unwrap();
let t = flat(&page_content_bytes(&rgbd, 0));
assert!(t.contains("1 0 0 rg 30 30 20 20 re f"), "{t}");
assert!(t.contains("1 0 0 rg 10 10 50 50 re f"), "already RGB, kept: {t}");
}
/// Fix hairlines: zero-width strokes and strokes thinner than the threshold on
/// the page (after the CTM) get the chosen width, in page content and the forms it draws; wider
/// lines, and glyphs and patterns unless asked, are left alone.
@@ -9577,6 +9946,102 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() {
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched");
}
/// `'` moves to the next line before it shows its text, and `"` also sets the word and character
/// spacing (ISO 32000-2 §9.4.3, Table 107). Rewriting a paragraph drawn with them drops the
/// operator but must keep what it did: text after it, positioned from that line or using that
/// spacing, stays exactly where and how it was. Both the single-style paragraph path and the
/// region path (a draft with a line break) are checked.
#[test]
fn rewriting_a_paragraph_drawn_with_quote_operators_keeps_their_line_move_and_spacing() {
let cases = [
// The next line is placed relative to the line `'` moved to.
"BT /F1 12 Tf 14 TL 72 714 Td (Target line here) ' 0 -100 Td (Footer stays put) Tj ET",
// The same with `"`, whose spacing the footer also inherits.
"BT /F1 12 Tf 14 TL 72 714 Td 3 2 (Target line here) \" 0 -100 Td (Footer stays put) Tj ET",
// Spacing set by `"` carries over into the next text object.
"BT /F1 12 Tf 14 TL 72 714 Td 3 2 (Target line here) \" ET BT 72 600 Td (Footer stays put) Tj ET",
// A split text object: text before the paragraph stays first, then the `'` lines go.
"BT /F1 12 Tf 14 TL 72 760 Td (Heading words) Tj 0 -46 Td (Target line here) Tj (more target) ' 0 -100 Td (Footer stays put) Tj ET",
// Split at a `'`: the rewrite's `Tm` there is already past its line move.
"BT /F1 12 Tf 14 TL 72 760 Td (Heading words) Tj 0 -32 Td (Target line here) ' (more target) ' 0 -100 Td (Footer stays put) Tj ET",
];
for content in cases {
let footer = |doc: &Document| {
let lines = text::text_lines(doc, 0).unwrap();
let f = lines.iter().find(|l| l.text == "Footer stays put").unwrap_or_else(|| panic!("no footer in {content}"));
(f.rect, f.ink.clone())
};
let original = text_page(content);
let before = footer(&original);
let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text.starts_with("Target")).unwrap();
for draft in ["NEW TEXT", "NEW\nTEXT"] {
let mut doc = original.clone();
text::replace_block(&mut doc, 0, target, draft).unwrap();
let doc = reopen(&doc);
assert_eq!(footer(&doc), before, "{content} with {draft:?}");
let texts: Vec<String> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert!(texts.iter().any(|t| t.contains("NEW")) && !texts.iter().any(|t| t.contains("Target")), "{content} with {draft:?}: {texts:?}");
}
}
}
/// [`text::replace_line`] also drops copies of the line drawn on the same baseline (a fake-bold
/// overprint). A copy drawn with `'` or `"` must leave that operator's line move and spacing
/// behind (ISO 32000-2 §9.4.3, Table 107), like the line's own first operator does: text after
/// it is positioned from that line or uses that spacing. Each copy is tried as the target, so the
/// one drawn with the quote operator is both the replaced line and the dropped copy.
#[test]
fn replacing_a_line_keeps_what_a_dropped_quote_copy_did() {
let cases = [
// The copy's `'` moves to the line the footer is placed from.
"BT /F1 12 Tf 14 TL 72 700 Td (Target line here) Tj ET BT 72.3 714 Td (Target line here) ' 0 -100 Td (Footer stays put) Tj ET",
// The copy's `"` sets the spacing the footer inherits in the next text object.
"BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td 3 2 (Target line here) \" ET BT 72 600 Td (Footer stays put) Tj ET",
];
for content in cases {
let footer = |doc: &Document| {
let lines = text::text_lines(doc, 0).unwrap();
let f = lines.iter().find(|l| l.text == "Footer stays put").unwrap_or_else(|| panic!("no footer in {content}"));
(f.rect, f.ink.clone())
};
let original = text_page(content);
let before = footer(&original);
let targets: Vec<usize> =
text::text_lines(&original, 0).unwrap().iter().enumerate().filter(|(_, l)| l.text == "Target line here").map(|(i, _)| i).collect();
assert_eq!(targets.len(), 2, "{content}");
for target in targets {
let mut doc = original.clone();
text::replace_line(&mut doc, 0, target, "NEW TEXT").unwrap();
let doc = reopen(&doc);
assert_eq!(footer(&doc), before, "{content} replacing line {target}");
let texts: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert!(texts.iter().any(|t| t == "NEW TEXT") && !texts.iter().any(|t| t.contains("Target")), "{content}: {texts:?}");
}
}
}
/// A copy dropped with the line it overprints moved the pen by its text: a run after it in its
/// text object, placed by that pen, stays where it was (ISO 32000-2 §9.4.4).
#[test]
fn replacing_a_line_keeps_the_advance_of_a_dropped_copy() {
for content in [
"BT /F1 12 Tf 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) Tj [-20000] TJ (Far) Tj ET",
"BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) ' [-20000] TJ (Far) Tj ET",
] {
let original = text_page(content);
let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "Far").map(|l| l.rect);
let before = place(&original).unwrap_or_else(|| panic!("Far is a line of its own in {content}"));
// The line drawn first, so the one followed by Far is the copy.
let target = text::text_lines(&original, 0).unwrap().iter().position(|l| l.text == "Target line here").unwrap();
let mut doc = original.clone();
text::replace_line(&mut doc, 0, target, "NEW TEXT").unwrap();
let doc = reopen(&doc);
let texts: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert!(!texts.iter().any(|t| t.contains("Target")), "{content}: the copy went too: {texts:?}");
assert_eq!(place(&doc), Some(before), "{content}");
}
}
/// #2512: a session's drafts are laid out from the document as it was opened, never from each
/// other, and its commit writes what a one-shot rewrite writes, whatever was laid out before.
#[test]
@@ -11934,16 +12399,19 @@ fn variation_public_line_and_block_edits_preserve_selectors_after_reopen() {
#[test]
fn variation_public_selector_does_not_shift_centered_text_with_character_spacing() {
// қ requires the same bundled fallback for both drafts, without optional font inputs.
for spacing in [0.0, 2.0] {
let mut plain = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let mut selected = plain.clone();
let style = text::BlockStyle { width: Some(100.0), align: Some(added::Align::Center), char_spacing: Some(spacing), ..Default::default() };
text::rewrite_block(&mut plain, 0, 0, Some("1қ"), &style).unwrap();
text::rewrite_block(&mut selected, 0, 0, Some("1\u{FE0F}қ"), &style).unwrap();
let a = text::text_lines(&reopen(&plain), 0).unwrap();
let b = text::text_lines(&reopen(&selected), 0).unwrap();
assert_eq!(a[0].base_font, b[0].base_font);
assert!(close(a[0].rect, b[0].rect), "Tc={spacing}: plain {:?}, selector {:?}", a[0].rect, b[0].rect);
// Exercise both retained source runs and a wholly substituted subset.
for resource in ["F1", "F2"] {
for spacing in [0.0, 2.0] {
let mut plain = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET"));
let mut selected = plain.clone();
let style = text::BlockStyle { width: Some(100.0), align: Some(added::Align::Center), char_spacing: Some(spacing), ..Default::default() };
text::rewrite_block(&mut plain, 0, 0, Some("1қ"), &style).unwrap();
text::rewrite_block(&mut selected, 0, 0, Some("1\u{FE0F}қ"), &style).unwrap();
let a = text::text_lines(&reopen(&plain), 0).unwrap();
let b = text::text_lines(&reopen(&selected), 0).unwrap();
assert_eq!(a[0].base_font, b[0].base_font);
assert!(close(a[0].rect, b[0].rect), "{resource}, Tc={spacing}: plain {:?}, selector {:?}", a[0].rect, b[0].rect);
}
}
}
/// Whole-paragraph alignment retains both soft wraps and typed hard breaks after save/reopen.
@@ -13503,6 +13971,124 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() {
}
}
/// Each `Tj`/`TJ` moves the text matrix by the width of its text. A run shown later in the same
/// text object without a `Td` or `Tm` of its own (a raised run in another colour, a column after
/// a `TJ` gap) is placed by those advances. Rewriting the paragraph before it must keep them, as
/// deleting a paragraph does: the run must not jump onto the new text. A kept run before the
/// paragraph splits the text object, and what reopens it keeps that run's advance too.
#[test]
fn rewriting_a_paragraph_keeps_later_runs_on_its_baseline_in_place() {
let cases = [
("BT /F1 12 Tf 72 700 Td (Target words) Tj 1 0 0 rg 3 Ts (Red) Tj ET", "Red"),
("BT /F1 12 Tf 72 700 Td (Target words) Tj [-20000] TJ (Far) Tj ET", "Far"),
// Several dropped runs: each one's advance is kept.
("BT /F1 12 Tf 72 700 Td (Target ) Tj (wor) Tj (ds) Tj [-20000] TJ (Far) Tj ET", "Far"),
// A kept run before the paragraph on its baseline: the text object is split there, and
// the pen it had reached is kept as well as the line matrix.
("BT /F1 12 Tf 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj ET", "Far"),
("BT /F1 12 Tf 72 700 Td (Left) Tj [-20000] TJ (Target ) Tj (words) Tj [-20000] TJ (Far) Tj ET", "Far"),
// The same with horizontal scaling and a later line placed from the line matrix.
("BT /F1 12 Tf 80 Tz 14 TL 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj T* (Next) Tj ET", "Far"),
("BT /F1 12 Tf 80 Tz 14 TL 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj T* (Next) Tj ET", "Next"),
// A `Q` puts back the text matrix its `q` saved: the `Tm` between them doesn't place `Far`.
("BT /F1 12 Tf 72 700 Td (Target words) Tj q 1 0 0 1 300 300 Tm Q [-20000] TJ (Far) Tj ET", "Far"),
// A `Q` with nothing saved restores nothing: the advance is still kept.
("BT /F1 12 Tf 72 700 Td (Target words) Tj Q [-20000] TJ (Far) Tj ET", "Far"),
];
for (content, run) in cases {
let original = text_page(content);
let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == run).map(|l| l.rect);
let before = place(&original).unwrap_or_else(|| panic!("{run} is a line of its own in {content}"));
let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text == "Target words").unwrap();
for draft in ["NEW TEXT", "NEW\nTEXT"] {
let mut doc = original.clone();
text::replace_block(&mut doc, 0, target, draft).unwrap();
let after = place(&reopen(&doc)).unwrap_or_else(|| panic!("{run} is gone: {content} with {draft:?}"));
assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-6), "{content} with {draft:?}: {before:?} -> {after:?}");
}
}
}
/// Vertical text advances down the column (ISO 32000-2 §9.4.4): runs dropped by a rewrite leave
/// that advance, so a run later in the same text object stays where it was.
#[test]
fn rewriting_vertical_runs_keeps_a_later_run_in_its_column_in_place() {
// The second with a horizontal scaling (`Tz`), which a vertical advance doesn't take.
for content in [
"BT /V1 20 Tf 500 720 Td <0041> Tj <0042> Tj [-8000] TJ <0043> Tj ET",
"BT /V1 20 Tf 80 Tz 500 720 Td <0041> Tj <0042> Tj [-8000] TJ <0043> Tj ET",
] {
vertical_runs_keep_a_later_run_in_place(content);
}
}
fn vertical_runs_keep_a_later_run_in_place(content: &str) {
let original = vertical_page(content);
let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "C").map(|l| l.rect);
let before = place(&original).unwrap_or_else(|| panic!("C is a line of its own: {:?}", text::text_lines(&original, 0)));
let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text == "AB").unwrap();
for draft in [
"D", "D
E",
] {
let mut doc = original.clone();
text::replace_block(&mut doc, 0, target, draft).unwrap();
let after = place(&reopen(&doc)).unwrap_or_else(|| panic!("C is gone with {draft:?}"));
assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-6), "{content} with {draft:?}: {before:?} -> {after:?}");
}
}
/// The advances read the glyphs without `/ActualText`, which can come to more text than the
/// reading the edit was laid out from. Past the page's text budget that way, no advance is kept
/// and the edit goes ahead, rather than refusing a page the reading allowed.
#[test]
fn dropped_advances_past_the_text_budget_keep_none_rather_than_refuse() {
let doc = text_page("BT /F1 12 Tf 72 700 Td (Drop) Tj [-20000] TJ (Far) Tj ET");
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
let drop: std::collections::HashSet<usize> = ops.iter().position(|o| o.is("Tj")).into_iter().collect();
let page = pdfcraft_model::pages(&doc)[0].dict.clone();
let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
assert_eq!(text::dropped_advances_within(&doc, &ops, &res, &drop, None).len(), 1, "the advance Far relies on");
assert!(text::dropped_advances_within(&doc, &ops, &res, &drop, Some(2)).is_empty(), "too much text to read: none");
}
#[test]
fn page_rules_refuse_unread_graphics_state() {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("UnsupportedFilter"));
let unread = Stream::from_raw(dict, b"2 0 0 2 0 0 cm".to_vec());
let damaged = damaged_flate(b"0 g\n", b"2 0 0 2 0 0 cm");
for stream in [unread, damaged] {
let mut doc = image_page_with("10 10 m 100 10 l S");
let incomplete = doc.add(Object::Stream(stream));
doc.update_dict(ObjRef::new(3, 0), |page| {
page.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(incomplete), Object::Ref(ObjRef::new(4, 0))]));
})
.unwrap();
assert!(page_rules(&doc, 0).is_err(), "later geometry cannot be trusted after unread graphics state");
}
assert_eq!(page_rules(&image_page_with("10 10 m 100 10 l S"), 0).unwrap(), [[10.0, 10.0, 100.0, 10.0]]);
}
#[test]
fn image_editing_frame_keeps_page_edge_outside_explicit_clips() {
for (prefix, expected) in [
("", [-30.0, -20.0, 70.0, 80.0]),
("-10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]),
("/PdfCraftImageCrop MP -10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]),
] {
let doc = image_page_with(&format!("q {prefix}100 0 0 100 -30 -20 cm /Im0 Do Q"));
for doc in [&doc, &reopen(&doc)] {
let im = images::page_images(doc, 0).unwrap().remove(0);
assert_eq!(im.frame_rect(), Some(expected));
assert_eq!(im.has_editor_crop(), prefix.starts_with("/PdfCraftImageCrop"));
assert_eq!(im.visible_rect.unwrap()[0..2], [0.0, 0.0]);
assert!(!im.contains([-5.0, 10.0]), "out-of-page pixels stay unselectable");
assert!(im.contains([10.0, 10.0]));
}
}
}
#[test]
fn explicit_builtin_thai_replaces_an_unavailable_source_without_installing_a_font() {
let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET");
@@ -14170,6 +14756,29 @@ fn legacy_thai_subsets_are_read_within_one_budget_per_edit() {
assert_eq!(pdfcraft_cos::write_full(&distinct, &Default::default()).unwrap(), before, "a refused edit changes nothing");
}
/// An edit whose THSarabunPSK subset spends the edit's allowance to the byte goes through: only a
/// read refused for want of it refuses the edit. The subset is read four times over its program
/// (the edit's metrics read, `LegacyThaiFont`'s, the editor's program read and its metrics read)
/// and three times over its ToUnicode, so a program 192 bytes short of 16 MiB and a ToUnicode of
/// 256 bytes cost exactly the 64 MiB; a byte more of ToUnicode is three past it.
#[test]
fn a_thai_edit_that_spends_its_allowance_to_the_byte_goes_through() {
let mut program = legacy_thai_program();
program.resize((16 << 20) - 192, 0);
let edit = |unicode_len: usize| {
let mut unicode = LEGACY_THAI_UNICODE.to_vec();
unicode.resize(unicode_len, b' ');
let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET");
let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &program, &unicode);
doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font));
let style = BlockStyle { width: Some(400.0), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).map(|_| ()).map_err(|e| e.to_string())
};
assert_eq!(edit(256), Ok(()));
let error = edit(257).unwrap_err();
assert!(error.contains("THSarabunPSK") && error.contains("MiB of font data"), "{error}");
}
/// A THSarabunPSK-named subset that has OpenType Thai mark positioning is a modern face: it is
/// shaped with its own layout like any source font, not by the legacy shaper (which would ask
/// for Windows PUA alternates such a face doesn't need).
+7 -5
View File
@@ -10,7 +10,7 @@
//! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted.
//! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`).
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops};
@@ -45,19 +45,21 @@ pub use blocks::{
use blocks::{kept_words, paragraph_separator, row_break};
pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts};
use editor::{builtin_thai_choice, layout_draft};
#[cfg(test)]
pub(crate) use fallback::SOURCE_SPACE_CALLS;
use fallback::{
MAX_TYPE3_GLYPHS, Type3Glyph, fallback_face, needs_type3, no_fallback_font, no_glyph_for, same_base_font, same_font, source_encodes,
source_family, source_program, source_show, source_space, source_width, type3_faces, type3_glyph, write_type3_glyphs,
};
#[cfg(test)]
pub(crate) use fallback::{SOURCE_SPACE_CALLS, take_source_character_measurements};
#[cfg(test)]
use fallback::{add_type3_font, is_type3_fallback};
pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name};
use interp::{Origin, Paint, union};
#[cfg(test)]
pub(crate) use lines::dropped_advances_within;
pub(crate) use lines::{
ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, first_op_after_damage, form_call,
joined, resource_dict, rewritten_contents, splice,
ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, dropped_advances,
dropped_side_effects, first_op_after_damage, form_call, joined, reopen_text_object, resource_dict, rewritten_contents, splice,
};
pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines};
use lines::{page_dict, word_lines};
+2 -8
View File
@@ -1,5 +1,5 @@
use super::interp::{Carry, interpret};
use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, splice, text_lines};
use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, show_side_effects, splice, text_lines};
use super::rewrite::{LineEdit, coincident_ops, rewrite_block};
use super::{Document, EditError, HashMap, Object, Op, TextLine, group_blocks, regions};
@@ -315,13 +315,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
}
let mut replacement = Vec::new();
if let Some(op) = ops.get(s.op) {
if op.is("\"") {
replacement.push(Op::new("Tw", vec![op.operands.first().cloned().unwrap_or(Object::Int(0))]));
replacement.push(Op::new("Tc", vec![op.operands.get(1).cloned().unwrap_or(Object::Int(0))]));
}
if op.is("'") || op.is("\"") {
replacement.push(Op::new("T*", vec![]));
}
replacement.extend(show_side_effects(op));
}
replacement.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])]));
patches.insert(s.op, replacement);
+35
View File
@@ -1,3 +1,4 @@
use super::HashSet;
use super::interp::Ts;
use super::lines::page_dict;
use super::{
@@ -630,6 +631,21 @@ pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<T
runs.into_iter().map(|(font, bytes, spacing)| Some(Type3Run { name: fallback.names.get(font)?.clone(), bytes, spacing })).collect()
}
/// Consecutive source/fallback runs, without allocating a list of input-derived runs.
pub(super) fn source_fallback_runs<'a>(mut text: &'a str, source_chars: Option<&'a HashSet<char>>) -> impl Iterator<Item = (bool, &'a str)> + 'a {
std::iter::from_fn(move || {
let first = text.chars().next()?;
let source = source_chars.is_some_and(|chars| chars.contains(&first));
let end = source_chars
.and_then(|chars| text.char_indices().find(|(_, ch)| chars.contains(ch) != source).map(|(offset, _)| offset))
.unwrap_or(text.len());
// char_indices supplies UTF-8 boundaries; get also handles an invalid range safely.
let run = text.get(..end)?;
text = text.get(end..)?;
Some((source, run))
})
}
/// The source font's own TrueType/OpenType program, when the document embeds a complete one whose
/// licence allows editable embedding. A subset (an `ABCDEF+` name) is left out: a character can keep
/// its mapping there after the subsetter dropped its outline.
@@ -702,6 +718,25 @@ pub(crate) fn source_width(metrics: &Metrics, text: &str, size: f64, space: f64,
words + text.matches(' ').count() as f64 * (space * size + char_spacing + word_spacing)
}
#[cfg(test)]
thread_local! {
static SOURCE_CHARACTER_MEASUREMENTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
pub(crate) fn take_source_character_measurements() -> usize {
SOURCE_CHARACTER_MEASUREMENTS.with(|count| count.replace(0))
}
/// An actually encodable source character in the normalized `Tw = 0` drawing state.
pub(super) fn source_character_advance(metrics: &Metrics, ch: char, size: f64, char_spacing: f64) -> Option<f64> {
#[cfg(test)]
SOURCE_CHARACTER_MEASUREMENTS.with(|count| count.set(count.get().saturating_add(1)));
let mut utf8 = [0; 4];
let bytes = metrics.encode(ch.encode_utf8(&mut utf8))?;
Some(metrics.codes(&bytes).iter().map(|(code, _)| metrics.width(*code) * size + char_spacing).sum())
}
pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacing: f64, word_spacing: f64) -> Result<Vec<Op>, EditError> {
if let Some(bytes) = metrics.encode(text) {
return Ok(vec![Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])]);
+140
View File
@@ -115,6 +115,146 @@ pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut D
Ok(out)
}
/// What a text-showing operator does besides showing text (ISO 32000-2 §9.4.3, Table 107): `'`
/// moves to the next line (`T*`), and `"` first sets the word and character spacing (`aw Tw`,
/// `ac Tc`). Text after it relies on both, so a dropped `'` or `"` leaves these in its place.
pub(crate) fn show_side_effects(op: &Op) -> Vec<Op> {
let mut out = Vec::new();
if op.is("\"") {
out.extend(op.num(0).map(|aw| Op::new("Tw", vec![pdfcraft_content::num(aw)])));
out.extend(op.num(1).map(|ac| Op::new("Tc", vec![pdfcraft_content::num(ac)])));
}
if op.is("'") || op.is("\"") {
out.push(Op::new("T*", vec![]));
}
out
}
/// For a paragraph or region rewrite: what dropped operator `i` must leave behind
/// ([`show_side_effects`]), so the text after it keeps its line and spacing. `split_at` is the
/// operator the text object was split at: the rewrite's `Tm` and state there already include
/// that operator's effects.
pub(crate) fn dropped_side_effects(ops: &[Op], i: usize, keep: bool, split_at: Option<usize>) -> Vec<Op> {
if keep || split_at == Some(i) {
return Vec::new();
}
ops.get(i).map(show_side_effects).unwrap_or_default()
}
/// For a paragraph or region rewrite that splits a text object: what reopens it after the
/// rewritten paragraph, so the text after it starts where it did. `tlm` and `tm` are the line and
/// text matrices where the paragraph began, and `size` and `scale` the font size (`Tf`) and
/// horizontal scaling (`Tz` / 100) in force there. A `Tm` restores the line matrix, which a later
/// `Td` or `T*` moves from; a kept run before the paragraph on the same baseline had already
/// advanced the pen beyond it (ISO 32000-2 §9.4.4), so a numeric `TJ` then moves the text matrix
/// alone by that advance. Only an advance along a horizontal baseline is expressible that way;
/// one that isn't (a vertical run's, or one at a zero size) leaves the pen at the line matrix.
pub(crate) fn reopen_text_object(tlm: [f64; 6], tm: [f64; 6], size: Option<f64>, scale: f64) -> Vec<Op> {
let n = pdfcraft_content::num;
let mut out = vec![Op::new("BT", vec![]), Op::new("Tm", tlm.iter().map(|v| n(*v)).collect())];
// tm = [1 0 0 1 tx ty] x tlm: solve for the advance (tx, ty) in text space.
let (dx, dy) = (tm[4] - tlm[4], tm[5] - tlm[5]);
let det = tlm[0] * tlm[3] - tlm[1] * tlm[2];
let tx = (dx * tlm[3] - dy * tlm[2]) / det;
let ty = (dy * tlm[0] - dx * tlm[1]) / det;
let denominator = size.unwrap_or(0.0) * scale;
let adjustment = -1000.0 * tx / denominator;
if tx.is_finite() && ty.is_finite() && tx.abs() > 1e-9 && ty.abs() <= 1e-9 && denominator != 0.0 && adjustment.is_finite() {
out.push(Op::new("TJ", vec![Object::Array(vec![n(adjustment)])]));
}
out
}
/// For a paragraph or region rewrite: a numeric `TJ` per dropped show operator in `drop`, moving
/// the text matrix as far as that operator's text did without drawing anything (as
/// [`super::clear_block`] does). Text shown later in the same text object without its own `Td`
/// or `Tm`, such as a run in another style or column on the same baseline, then stays where it
/// was. Only an advance such a run relies on is kept: none when the text object ends, or its next
/// kept run starts a line of its own (`Td`, `TD`, `Tm`, `T*`, `'`, `"`), first. An advance a `TJ`
/// can't express (spacing at a zero font size) is left out. `resources` is the page's resource
/// dictionary. This reads the glyphs without `/ActualText`, which can come to more text than the
/// reading the edit was laid out from; a page whose text that way exceeds the page's text budget
/// keeps no advances rather than refusing an edit the reading allowed.
pub(crate) fn dropped_advances(doc: &Document, ops: &[Op], resources: &Dict, drop: &std::collections::HashSet<usize>) -> HashMap<usize, Op> {
dropped_advances_within(doc, ops, resources, drop, None)
}
/// [`dropped_advances`] with `text_left` in place of the page's text budget (tests make it small).
pub(crate) fn dropped_advances_within(
doc: &Document,
ops: &[Op],
resources: &Dict,
drop: &std::collections::HashSet<usize>,
text_left: Option<usize>,
) -> HashMap<usize, Op> {
let mut out = HashMap::new();
let mut carry = Carry::new();
if let Some(left) = text_left {
carry.text_left = left;
}
let follows = kept_run_follows(ops, drop);
let Ok(shown) = interpret(doc, ops, resources, &mut HashMap::new(), &mut carry, None, None) else {
return out;
};
for s in shown {
if !drop.contains(&s.op) || s.advance == 0.0 || !follows.get(s.op).copied().unwrap_or(false) {
continue;
}
let denominator = s.state.size * s.state.scale;
let adjustment = -1000.0 * s.advance / denominator;
if denominator != 0.0 && adjustment.is_finite() {
out.entry(s.op).or_insert_with(|| Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])]));
}
}
out
}
/// For each operator: whether a run shown after it in its text object, and not in `drop`, is
/// placed by the pen it left rather than by a line move of its own (see [`dropped_advances`]).
/// A `Q` puts back the text matrices its `q` saved (as [`interpret`] reads it), so a line move
/// between them doesn't count for the text after the `Q`. Leaving an advance in is harmless where
/// nothing turns out to use it. One pass from the end, so a page of many dropped runs stays linear.
fn kept_run_follows(ops: &[Op], drop: &std::collections::HashSet<usize>) -> Vec<bool> {
// The `q` and `Q` that pair up: [`interpret`] ignores a `Q` with nothing saved, so only a
// `Q` that restores something is a boundary here.
let mut paired = vec![false; ops.len()];
let mut open: Vec<usize> = Vec::new();
for (i, op) in ops.iter().enumerate() {
if op.is("q") {
open.push(i);
} else if op.is("Q")
&& let Some(q) = open.pop()
{
for at in [q, i] {
if let Some(slot) = paired.get_mut(at) {
*slot = true;
}
}
}
}
let mut out = vec![false; ops.len()];
let mut follows = false;
// What followed each `Q` still open (read backwards), restored at its `q`.
let mut after_restore: Vec<bool> = Vec::new();
for (i, op) in ops.iter().enumerate().rev() {
if let Some(slot) = out.get_mut(i) {
*slot = follows;
}
let pairs = paired.get(i).copied().unwrap_or(false);
match op.op.as_slice() {
b"Q" if pairs => {
after_restore.push(follows);
follows = false;
}
b"q" if pairs => follows = follows || after_restore.pop().unwrap_or(false),
b"ET" | b"BT" | b"Td" | b"TD" | b"Tm" | b"T*" | b"'" | b"\"" => follows = false,
b"Tj" | b"TJ" if !drop.contains(&i) => follows = true,
_ => {}
}
}
out
}
/// The page's streams joined into the one stream they are: an operator may span two of them.
pub(crate) fn joined(streams: &[ContentStream]) -> Pieces {
Pieces::join(&streams.iter().map(|s| s.data.as_slice()).collect::<Vec<_>>())
+105 -44
View File
@@ -1,13 +1,17 @@
use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap};
use super::fallback::{
Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font,
source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font,
source_character_advance, source_encodes, source_fallback_runs, source_family, source_program_completes, source_show, source_space, source_width,
type3_encode, type3_font,
};
use super::interp::{Carry, Origin, TextState, interpret};
use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines};
use super::lines::{
content_streams, dropped_advances, dropped_side_effects, joined, page_dict, reopen_text_object, rewritten_contents, show_side_effects, splice,
text_lines,
};
use super::{
Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, is_centred,
parse, rewrite_changed_words, rewrite_styled_block,
Dict, Document, EditError, HashMap, HashSet, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks,
is_centred, parse, rewrite_changed_words, rewrite_styled_block,
};
/// What replacing a line did.
@@ -68,16 +72,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
let reused = font.as_ref().filter(|m| source_encodes(m, &shown));
let mut substituted = None;
let mut replacement: Vec<Op> = Vec::new();
// ' and " also move to the next line; keep that.
match ops[first].op.as_slice() {
b"'" => replacement.push(Op::new("T*", vec![])),
b"\"" => {
replacement.push(Op::new("Tw", vec![ops[first].operands.first().cloned().unwrap_or(Object::Int(0))]));
replacement.push(Op::new("Tc", vec![ops[first].operands.get(1).cloned().unwrap_or(Object::Int(0))]));
replacement.push(Op::new("T*", vec![]));
}
_ => {}
}
// ' and " also move to the next line (and " sets the spacing); keep that.
replacement.extend(ops.get(first).map(show_side_effects).unwrap_or_default());
match reused {
Some(metrics) => replacement.extend(source_show(
metrics,
@@ -134,8 +130,23 @@ 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.
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
// Rebuild: the line's first operator becomes the replacement, its others go. Each other
// dropped `'` or `"` (a later run, or a copy on the same baseline) leaves its line move and
// spacing behind, as the first one does inside the replacement, and a dropped copy its advance
// where text after it is placed by that (the line's own runs give way to the replacement's).
let mut advances = dropped_advances(doc, &ops, &res, &drops);
for i in &target.ops {
advances.remove(i);
}
let new_data = splice(&pieces, &ops, |i| {
if i == first {
return (std::mem::take(&mut replacement), false);
}
let keep = !drops.contains(&i);
let mut insert = dropped_side_effects(&ops, i, keep, None);
insert.extend(advances.remove(&i));
(insert, keep)
});
let contents = rewritten_contents(doc, &streams, new_data)?;
let page_ref = p.obj;
if substituted.is_some() {
@@ -344,6 +355,7 @@ fn turned_line_width(first: &TextLine, crop: [f64; 4], offset: [f64; 2], width:
/// The widths of the paragraph's text in the font it will be written in, in user space.
struct Advance<'a> {
partial_advances: &'a HashMap<char, Option<f64>>,
/// The source font, when its glyphs are reused.
source: Option<&'a Metrics>,
type3: Option<&'a Type3Fallback>,
@@ -370,11 +382,24 @@ impl Advance<'_> {
(None, Some(fallback)) => {
// Each character's count in `s`, from one pass over `s`.
let mut counts: HashMap<char, usize> = HashMap::new();
let mut source_advance = 0.0;
let mut fallback_count = 0usize;
for c in s.chars() {
let count = counts.entry(c).or_insert(0);
*count = count.saturating_add(1);
// The selector is emitted with zero spacing, so it cannot widen the line.
if is_variation_selector(c) {
continue;
}
if let Some(width) = self.partial_advances.get(&c).copied().flatten() {
source_advance += width;
} else {
let count = counts.entry(c).or_insert(0);
*count = count.saturating_add(1);
fallback_count = fallback_count.saturating_add(1);
}
}
fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::<f64>() + extra
source_advance
+ fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::<f64>()
+ fallback_count as f64 * self.char_spacing
}
(None, None) => self.family.std14(self.bold, self.italic).text_width(s, self.size) + extra,
};
@@ -541,6 +566,8 @@ impl Alignment {
/// Everything the wrapped lines of one paragraph are written with.
struct LineWriter<'a> {
source_name: &'a str,
partial_chars: Option<&'a HashSet<char>>,
advance: &'a Advance<'a>,
alignment: Alignment,
encode: &'a Encoder,
@@ -649,7 +676,8 @@ impl LineWriter<'_> {
}
// Each wrapped line in typing order; drawn left to right (right-to-left runs reversed).
let visual = crate::bidi::to_visual(shown);
if let Some(metrics) = self.source.filter(|m| m.encode(" ").is_none()) {
// Partial reuse must reach show_piece: its missing spaces and glyphs belong to the fallback.
if let Some(metrics) = self.source.filter(|m| self.partial_chars.is_none() && m.encode(" ").is_none()) {
let indic_line = if metrics.visual_order { crate::indic::to_visual(&visual) } else { visual };
ops.extend(source_show(metrics, &indic_line, self.size, self.space, self.char_spacing, tw + stretch)?);
} else {
@@ -670,24 +698,30 @@ impl LineWriter<'_> {
fn show_piece(&self, piece: &str, line: &str, tw: f64, cursor: &mut LineCursor, ops: &mut Vec<Op>) -> Result<(), EditError> {
let n = pdfcraft_content::num;
if let Some(metrics) = self.source {
let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() };
ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?);
return Ok(());
}
for run in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? {
if run.name != cursor.shown_font {
ops.push(Op::new("Tf", vec![Object::name(&run.name), n(self.size)]));
cursor.shown_font = run.name;
}
if let Some(tc) = run.spacing {
// A variation selector's empty glyph: shown at spacing zero, then the paragraph's
// own spacing back, so it adds no advance.
ops.push(Op::new("Tc", vec![n(tc)]));
ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
ops.push(Op::new("Tc", vec![n(self.char_spacing)]));
for (source, run) in source_fallback_runs(piece, self.partial_chars) {
if let Some(metrics) = self.source.filter(|_| self.partial_chars.is_none() || source) {
if cursor.shown_font != self.source_name {
ops.push(Op::new("Tf", vec![Object::name(self.source_name), n(self.size)]));
cursor.shown_font = self.source_name.to_owned();
}
let indic_piece = if metrics.visual_order { crate::indic::to_visual(run) } else { run.to_owned() };
ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?);
} else {
ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
for run in (self.encode)(run).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? {
if run.name != cursor.shown_font {
ops.push(Op::new("Tf", vec![Object::name(&run.name), n(self.size)]));
cursor.shown_font = run.name;
}
if let Some(tc) = run.spacing {
// A variation selector's empty glyph: shown at spacing zero, then the paragraph's
// own spacing back, so it adds no advance.
ops.push(Op::new("Tc", vec![n(tc)]));
ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
ops.push(Op::new("Tc", vec![n(self.char_spacing)]));
} else {
ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
}
}
}
}
Ok(())
@@ -881,6 +915,23 @@ pub(crate) fn rewrite_block_from(
let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style, b.phrase_size())?;
let source = source_font(doc, &fonts_res, &font_name, first, &flat, style);
let reuse = source.reuse;
// Preserve every drawable source glyph; cache missing-glyph lookups as well.
let visual_indic = source.metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order;
let hebrew = flat.chars().any(crate::bidi::is_hebrew);
let source_advances: HashMap<char, Option<f64>> = source
.metrics
.as_ref()
.filter(|_| !reuse && upright && !hebrew && !visual_indic && style.family.is_none() && style.bold.is_none() && needs_type3(&flat))
.map(|m| {
let mut advances = HashMap::new();
for ch in flat.chars() {
advances.entry(ch).or_insert_with(|| source_character_advance(m, ch, size, o_state.char_spacing));
}
advances
})
.unwrap_or_default();
let source_chars: HashSet<char> = source_advances.iter().filter_map(|(&ch, width)| width.map(|_| ch)).collect();
let partial_reuse = !source_chars.is_empty();
// 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);
@@ -910,9 +961,15 @@ pub(crate) fn rewrite_block_from(
let right_edge = if anchored_right { (b.rect[2] - b.rect[0]).max(0.0) } else { width };
let movement = drawn_movement(emit.ctm, dx, dy)?;
let tm = emit_text_matrix(o, emit)?;
let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None };
let type3 = if !reuse && needs_type3(&flat) {
let missing: String = flat.chars().filter(|ch| !source_chars.contains(ch)).collect();
Some(type3_font(doc, &mut fonts_res, &missing, family, bold, max_glyphs)?)
} else {
None
};
let source_metrics = source.metrics.as_ref().filter(|_| reuse);
let advance = Advance {
partial_advances: &source_advances,
source: source_metrics,
type3: type3.as_ref(),
family,
@@ -958,10 +1015,12 @@ pub(crate) fn rewrite_block_from(
None => type3.is_none(),
};
let writer = LineWriter {
source_name: &font_name,
partial_chars: partial_reuse.then_some(&source_chars),
advance: &advance,
alignment: Alignment { width, k, anchored_right, right_edge, rtl },
encode: &font.encode,
source: source_metrics,
source: source.metrics.as_ref().filter(|_| reuse || partial_reuse),
align,
aligns: &style.aligns,
word_spacing_reaches_spaces,
@@ -995,16 +1054,18 @@ pub(crate) fn rewrite_block_from(
};
let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?;
let on_top = crate::lift_on_top(over, &mut block_ops);
let n = pdfcraft_content::num;
block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some()));
if split {
block_ops.insert(0, Op::new("ET", vec![]));
block_ops.push(Op::new("BT", vec![]));
block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect()));
block_ops.extend(reopen_text_object(emit.tlm, emit.tm, after.font.as_ref().map(|(_, size)| *size), after.scale));
}
let mut advances = dropped_advances(doc, &ops, &res, &dropped);
let new_data = splice(&pieces, &ops, |i| {
let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() };
(insert, !dropped.contains(&i))
let mut insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() };
let keep = !dropped.contains(&i);
insert.extend(dropped_side_effects(&ops, i, keep, split.then_some(start)));
insert.extend(advances.remove(&i));
(insert, keep)
});
let contents = rewritten_contents(doc, &streams, new_data)?;
let new_font = !reuse;
+2 -2
View File
@@ -150,8 +150,8 @@ fn key(line: &TextLine) -> (&str, [i64; 4]) {
}
/// How far right a rewrite of the runs `indices` drew (`None` when it drew nothing), or an error
/// when it moved or lost any other text on the page: text positioned after the end of the text
/// before it, say, depends on the words that went.
/// when it moved or lost any other text on the page: text positioned after the end of the words
/// that went, say, when their advance couldn't be left behind (see `dropped_advances`).
fn drawn_right(before: &[TextLine], indices: &[usize], after: &[TextLine]) -> Result<Option<f64>, EditError> {
let mut unmatched: HashMap<_, usize> = HashMap::new();
for line in before {
+37 -12
View File
@@ -116,8 +116,11 @@ struct LegacyFace {
space: f64,
}
/// What one edit may decode, and so keep, of the font programs of its THSarabunPSK subsets
/// (each at most 16 MiB): a page may name many of them.
/// What one edit may decode of its THSarabunPSK subsets' font data: their programs (each at most
/// 16 MiB), which it keeps, and the streams of the metrics read to measure them and to adapt their
/// programs' cmaps: their CMaps and glyph maps, and a subset's program again, for which of its
/// glyphs have outlines. A page may name many of them. The metrics read to find the subsets among
/// the page's fonts aren't charged to it.
const MAX_THAI_PROGRAMS: usize = 64 << 20;
struct PreparedCharacters {
@@ -900,6 +903,9 @@ fn legacy_thai_font(
}
let family = pdfcraft_fonts::embedding::face_key(&source.base_font);
let mut candidates = vec![source.font.clone()];
// Every name on the page is read here for its face name alone, aliases of one font and other
// faces' fonts too, with no total, as before: charged to the edit's allowance, each alias would
// pay again and other faces would spend what the face's subsets need.
candidates.extend(fonts.iter().filter_map(|(name, font)| {
let font = doc.dict(font)?;
(pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family).then(|| String::from_utf8_lossy(name).into_owned())
@@ -914,9 +920,15 @@ fn legacy_thai_font(
let face = match object.and_then(|o| cache.values().find(|face| face.object == Some(o))) {
Some(alias) => LegacyFace { glyphs: alias.glyphs.clone(), metrics: alias.metrics.clone(), object, ..*alias },
None => {
let metrics = Metrics::from_dict(doc, &font);
let space = source_space(doc, fonts, &metrics);
let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new);
// Read within the edit's allowance; past it the subset goes unread, and the edit is
// refused below.
let metrics = Metrics::from_dict_within(doc, &font, budget);
let (space, glyphs) = if metrics.past_allowance {
(0.0, None)
} else {
let space = source_space(doc, fonts, &metrics);
(space, pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new))
};
let style = FontStyle::of(doc, &font, &metrics);
LegacyFace { glyphs, metrics, style, object, space }
}
@@ -924,9 +936,11 @@ fn legacy_thai_font(
cache.insert(name, face);
}
// A subset left unread would count as unusable, silently changing which subset draws the text.
if budget.remaining() == 0 {
// A read refused for want of the allowance is what refuses the edit: reads that spent it to the
// byte refused nothing.
if budget.refused() > 0 {
return Err(EditError::Invalid(format!(
"the page's {} subsets need more than {} MiB of font programs for one edit; choose a replacement font for this text",
"the page's {} subsets need more than {} MiB of font data for one edit; choose a replacement font for this text",
source.base_font.rsplit('+').next().unwrap_or(&source.base_font),
MAX_THAI_PROGRAMS >> 20
)));
@@ -1532,7 +1546,8 @@ fn push_row_break_marker(block_ops: &mut Vec<Op>, row_soft: &[bool], row_gap: &[
/// [`splice`] with the rewritten rows spliced in at `at`, and (when `marker` names one) a `/P`
/// marker opened at its start and closed at its `next` line or, when nothing follows, after the
/// paragraph's own last `ET` (appended past the stream's last operator when that close is at or
/// beyond `ops.len()`, since `splice`'s callback never visits that index).
/// beyond `ops.len()`, since `splice`'s callback never visits that index). A dropped operator
/// `i` leaves `left_behind(i, keep)` in its place: its line move, spacing and advance.
fn splice_with_marker(
pieces: &Pieces,
ops: &[Op],
@@ -1540,6 +1555,7 @@ fn splice_with_marker(
at: usize,
mut block_ops: Vec<Op>,
drop: &std::collections::HashSet<usize>,
mut left_behind: impl FnMut(usize, bool) -> Vec<Op>,
) -> Vec<Vec<u8>> {
let data = splice(pieces, ops, |i| {
let mut insert = Vec::new();
@@ -1557,7 +1573,9 @@ fn splice_with_marker(
if i == at {
insert.append(&mut block_ops);
}
(insert, !drop.contains(&i))
let keep = !drop.contains(&i);
insert.extend(left_behind(i, keep));
(insert, keep)
});
if !marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) {
return data;
@@ -1882,14 +1900,21 @@ pub(crate) fn rewrite_styled_block(
block_ops.extend(after.ops());
if split {
block_ops.insert(0, Op::new("ET", vec![]));
block_ops.push(Op::new("BT", vec![]));
block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect()));
block_ops.extend(crate::text::reopen_text_object(o.tlm, o.tm, after.font.as_ref().map(|(_, size)| *size), after.scale));
}
// A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is
// safe. The close is not part of `block_ops`: that vector can end inside a text object
// when the rewrite splits one, and a kept row's operators come later in the stream.
let marker = if marked { None } else { paragraph_marker(block, lines, &ops) };
let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop);
// A dropped `'` or `"` leaves its line move and spacing, and a dropped show its advance.
let mut advances = crate::text::dropped_advances(doc, &ops, &res, &drop);
let split_at = split.then_some(start);
let left_behind = |i: usize, keep: bool| {
let mut out = crate::text::dropped_side_effects(&ops, i, keep, split_at);
out.extend(advances.remove(&i));
out
};
let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop, left_behind);
let contents = rewritten_contents(doc, &streams, data)?;
res.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p.obj, |d| {
@@ -0,0 +1,71 @@
//! Convert colours: `/DeviceCMYK cs` starts at black (0 0 0 1, ISO 32000-1 8.6.5.5), so the
//! converted operator is black too, not the white that four zeros would be.
use pdfcraft_cos::{Document, ObjRef, Object, Stream};
use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours};
use std::sync::Arc;
/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}`
/// entry is written as a stream holding the text after the colon, with its `/Length`.
fn pdf(objs: &[&str]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
let body = match o.split_once(">>stream:") {
Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()),
None => (*o).to_string(),
};
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn flat(b: &[u8]) -> String {
String::from_utf8_lossy(b).split_whitespace().collect::<Vec<_>>().join(" ")
}
fn stream_text(doc: &Document, r: ObjRef) -> String {
match &*doc.get(r) {
Object::Stream(s) => flat(&s.decoded().unwrap()),
_ => panic!("not a stream"),
}
}
/// The page's `/Contents` references.
fn contents(doc: &Document, page: usize) -> Vec<ObjRef> {
let p = pdfcraft_model::pages(doc).swap_remove(page);
match &*doc.resolve(p.dict.get(b"Contents").unwrap()) {
Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(),
_ => p.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(),
}
}
fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option<Stream> {
None
}
fn page_with(content: &str) -> Document {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>",
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>",
&format!("<< >>stream:{content}"),
])
}
#[test]
fn device_cmyk_starts_at_black() {
let mut doc = page_with("/DeviceCMYK cs 0 0 10 10 re f /DeviceCMYK CS 0 0 m 10 10 l S");
let s = ConvertColours { preserve_black: false, ..ConvertColours::default() };
let done = convert_colours(&mut doc, &[0], &s, &mut no_images).unwrap();
assert_eq!(done.colours, 2);
let t = stream_text(&doc, contents(&doc, 0)[0]);
assert!(t.contains("0 g 0 0 10 10 re f") && t.contains("0 G 0 0 m 10 10 l S"), "black, not white: {t}");
}
@@ -0,0 +1,144 @@
//! Convert colours on some pages leaves the others as they were: a form or picture they share
//! is converted as a copy for the pages being converted, and the resource dictionary they
//! share is not changed.
use pdfcraft_cos::{Document, ObjRef, Object, Stream};
use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours};
use std::sync::Arc;
/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}`
/// entry is written as a stream holding the text after the colon, with its `/Length`.
fn pdf(objs: &[&str]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
let body = match o.split_once(">>stream:") {
Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()),
None => (*o).to_string(),
};
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn flat(b: &[u8]) -> String {
String::from_utf8_lossy(b).split_whitespace().collect::<Vec<_>>().join(" ")
}
fn stream_text(doc: &Document, r: ObjRef) -> String {
match &*doc.get(r) {
Object::Stream(s) => flat(&s.decoded().unwrap()),
_ => panic!("not a stream"),
}
}
fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option<Stream> {
None
}
/// Two pages sharing one resource dictionary (inherited from the page tree) whose form draws
/// red, and a form inside it that draws blue.
fn two_pages() -> Document {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] /Resources 5 0 R >>",
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>",
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>",
"<< /XObject << /Fm1 7 0 R >> >>",
"<< >>stream:/Fm1 Do",
"<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /XObject << /Fm2 8 0 R >> >> >>stream:1 0 0 rg 0 0 10 10 re f /Fm2 Do",
"<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] >>stream:0 0 1 rg 0 0 5 5 re f",
])
}
fn form_of(doc: &Document, page: usize, name: &[u8]) -> ObjRef {
let p = pdfcraft_model::pages(doc).swap_remove(page);
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
xo.as_dict().unwrap().get(name).and_then(Object::as_ref).unwrap()
}
fn inner_form(doc: &Document, outer: ObjRef) -> ObjRef {
match &*doc.get(outer) {
Object::Stream(s) => {
let res = doc.resolve(s.dict.get(b"Resources").unwrap());
let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
xo.as_dict().unwrap().get(b"Fm2").and_then(Object::as_ref).unwrap()
}
_ => panic!("not a stream"),
}
}
#[test]
fn a_shared_form_is_converted_as_a_copy() {
let mut doc = two_pages();
let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap();
assert_eq!(done.colours, 2);
// The other page still draws the original forms, in their colours.
let kept = form_of(&doc, 1, b"Fm1");
assert!(stream_text(&doc, kept).contains("1 0 0 rg 0 0 10 10 re f"), "{}", stream_text(&doc, kept));
assert!(stream_text(&doc, inner_form(&doc, kept)).contains("0 0 1 rg 0 0 5 5 re f"));
// The converted page draws copies, the inner one too.
let copy = form_of(&doc, 0, b"Fm1");
assert_ne!(copy, kept);
assert!(stream_text(&doc, copy).contains("0.299 g 0 0 10 10 re f"), "{}", stream_text(&doc, copy));
assert!(stream_text(&doc, inner_form(&doc, copy)).contains("0.114 g 0 0 5 5 re f"));
}
#[test]
fn converting_every_page_changes_the_forms_in_place() {
let mut doc = two_pages();
let before = form_of(&doc, 0, b"Fm1");
convert_colours(&mut doc, &[0, 1], &ConvertColours::default(), &mut no_images).unwrap();
assert_eq!(form_of(&doc, 0, b"Fm1"), before);
assert_eq!(form_of(&doc, 1, b"Fm1"), before);
assert!(stream_text(&doc, before).contains("0.299 g 0 0 10 10 re f"));
}
/// One page of `content` with `res` as its resources, followed by `more` objects from 5 up.
fn page_of(res: &str, content: &str, more: &[&str]) -> Document {
let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources {res} >>");
let content = format!("<< >>stream:{content}");
let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", page.as_str(), content.as_str()];
objs.extend_from_slice(more);
pdf(&objs)
}
/// The `/None` colorant paints nothing; a colour of its own would make it appear.
#[test]
fn the_none_colorant_stays() {
let tint = "<< /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0 0 1] /N 1 >>";
let res = format!(
"<< /ColorSpace << /S [/Separation /None /DeviceCMYK {tint}] /D [/DeviceN [/None /None] /DeviceCMYK {tint}] /Real [/Separation /Red /DeviceCMYK {tint}] >> >>"
);
let content = "/S cs 1 scn 0 0 9 9 re f /D cs 1 1 scn 0 0 9 9 re f";
let mut doc = page_of(&res, content, &[]);
assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 0);
assert_eq!(stream_text(&doc, ObjRef { num: 4, generation: 0 }), content);
let mut doc = page_of(&res, "/Real cs 1 scn 0 0 9 9 re f", &[]);
assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 2, "a real spot is converted");
}
/// Pictures the helper cannot re-encode are counted, unless they are in the target already or
/// have no colours of their own.
#[test]
fn pictures_left_alone_are_counted() {
const IMAGE: &str = "/Type /XObject /Subtype /Image /Width 1 /Height 1";
let indexed = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 0 <FF0000>] >>stream:x");
let gray_table = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceGray 0 <80>] >>stream:x");
let gray = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace /DeviceGray >>stream:x");
let stencil = format!("<< {IMAGE} /BitsPerComponent 1 /ImageMask true >>stream:x");
let res = "<< /XObject << /A 5 0 R /B 6 0 R /C 7 0 R /D 8 0 R >> >>";
let mut doc = page_of(res, "/A Do /B Do /C Do /D Do", &[&indexed, &gray_table, &gray, &stencil]);
let mut none = doc.clone();
assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().images_left, 1);
let s = ConvertColours { images: false, ..ConvertColours::default() };
assert_eq!(convert_colours(&mut none, &[0], &s, &mut no_images).unwrap().images_left, 0, "pictures were not asked for");
}
@@ -0,0 +1,100 @@
//! Convert colours rewrites each of a page's content streams on its own: a stream keeps its
//! dictionary (its `/PCMark` among it), unchanged streams stay as they are, the colour space
//! set in one stream holds in the next, and a page with an unreadable stream is left alone.
use pdfcraft_cos::{Document, ObjRef, Object, Stream};
use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours};
use std::sync::Arc;
/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}`
/// entry is written as a stream holding the text after the colon, with its `/Length`.
fn pdf(objs: &[&str]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
let body = match o.split_once(">>stream:") {
Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()),
None => (*o).to_string(),
};
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn flat(b: &[u8]) -> String {
String::from_utf8_lossy(b).split_whitespace().collect::<Vec<_>>().join(" ")
}
fn stream_text(doc: &Document, r: ObjRef) -> String {
match &*doc.get(r) {
Object::Stream(s) => flat(&s.decoded().unwrap()),
_ => panic!("not a stream"),
}
}
/// The page's `/Contents` references.
fn contents(doc: &Document, page: usize) -> Vec<ObjRef> {
let p = pdfcraft_model::pages(doc).swap_remove(page);
match &*doc.resolve(p.dict.get(b"Contents").unwrap()) {
Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(),
_ => p.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(),
}
}
fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option<Stream> {
None
}
/// One page drawing `streams`, with an indexed colour space `/CS0` (0 = red, 1 = green).
fn page_with(streams: &[&str]) -> Document {
let refs: Vec<String> = (0..streams.len()).map(|i| format!("{} 0 R", i + 4)).collect();
let page = format!(
"<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << /CS0 [/Indexed /DeviceRGB 1 <FF000000FF00>] >> >> /Contents [{}] >>",
refs.join(" ")
);
let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", &page];
objs.extend_from_slice(streams);
pdf(&objs)
}
#[test]
fn each_stream_keeps_its_dictionary_and_only_changed_ones_are_replaced() {
let mut doc = page_with(&["<< >>stream:0.5 g 0 0 10 10 re f", "<< /PCMark /Redaction >>stream:1 0 0 rg 0 0 10 10 re f"]);
let before = contents(&doc, 0);
let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap();
assert_eq!(done.colours, 1);
let after = contents(&doc, 0);
assert_eq!(after.len(), 2, "still two streams");
assert_eq!(after[0], before[0], "the unchanged stream is the same object");
let mark = match &*doc.get(after[1]) {
Object::Stream(s) => s.dict.name(b"PCMark").map(<[u8]>::to_vec),
_ => None,
};
assert_eq!(mark.as_deref(), Some(&b"Redaction"[..]), "the mark is kept");
assert!(stream_text(&doc, after[1]).contains("0.299 g 0 0 10 10 re f"));
}
#[test]
fn the_colour_space_carries_from_one_stream_to_the_next() {
// Index 0 of the space set in the first stream is red; read as a gray, it would be black.
let mut doc = page_with(&["<< >>stream:/CS0 cs", "<< >>stream:0 sc 0 0 10 10 re f"]);
convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap();
let after = contents(&doc, 0);
assert!(stream_text(&doc, after[1]).contains("0.299 g 0 0 10 10 re f"), "{}", stream_text(&doc, after[1]));
}
#[test]
fn a_page_with_an_unreadable_stream_is_left_alone() {
let mut doc = page_with(&["<< >>stream:1 0 0 rg 0 0 10 10 re f", "<< /Filter /DCTDecode >>stream:not a picture"]);
let before = contents(&doc, 0);
let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap();
assert_eq!(done.colours, 0);
assert_eq!(contents(&doc, 0), before, "no stream is dropped");
}
+5
View File
@@ -0,0 +1,5 @@
//! The crate's integration tests, linked into one test binary.
mod convert_colours_cmyk_initial;
mod convert_colours_shared;
mod convert_colours_streams;
@@ -0,0 +1,12 @@
//! The page information line keeps accented letters: WinAnsi bytes from 0x80 up must reach the
//! content stream as escapes, not be replaced with U+FFFD.
use pdfcraft_edit::PrinterMarks;
#[test]
fn accented_label_is_written_as_winansi_escapes() {
let marks = PrinterMarks { label: "Café.pdf".into(), ..PrinterMarks::default() };
let content = marks.content([0.0, 0.0, 612.0, 792.0], [0.0, 0.0, 612.0, 792.0], 1, 1);
assert!(!content.contains('\u{FFFD}'), "no replacement character in the stream: {content:?}");
assert!(content.contains("(Caf\\351.pdf 1 of 1)"), "é is the WinAnsi byte 0xE9 written as \\351: {content:?}");
}
@@ -0,0 +1,50 @@
//! Bleed marks start at the bleed box, so the room made for the marks counts the bleed too.
use pdfcraft_cos::{Dict, Document, Object};
use pdfcraft_edit::{PrinterMarks, add_printer_marks};
fn rect(r: [f64; 4]) -> Object {
Object::Array(r.iter().map(|v| Object::Real(*v)).collect())
}
fn page_with(boxes: &[(&[u8], [f64; 4])]) -> Document {
let mut doc = Document::new_empty();
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Parent".to_vec(), Object::Ref(pages));
for (key, r) in boxes {
page.set(key.to_vec(), rect(*r));
}
let page = doc.add(page);
doc.update_dict(pages, |d| {
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
d.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
doc
}
fn page_box(doc: &Document, key: &[u8]) -> Option<Vec<f64>> {
let page = pdfcraft_model::pages(doc).first().cloned()?;
let a = page.dict.get(key).map(|o| doc.resolve(o))?;
Some(a.as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64()).collect())
}
/// A 9 pt bleed: the bleed marks run out to 9 + 6 + 18 = 33 pt from the trim box, further than the
/// usual 24, so the crop box grows by that much.
#[test]
fn crop_box_grows_to_hold_the_bleed_marks() {
let mut doc = page_with(&[
(b"MediaBox", [0.0, 0.0, 1000.0, 1000.0]),
(b"CropBox", [200.0, 200.0, 800.0, 800.0]),
(b"TrimBox", [200.0, 200.0, 800.0, 800.0]),
(b"BleedBox", [191.0, 191.0, 809.0, 809.0]),
]);
let marks = PrinterMarks::default();
assert_eq!(marks.reach(), 24.0);
let widened = add_printer_marks(&mut doc, &[0], &marks, false).unwrap();
assert_eq!(widened, vec![0]);
assert_eq!(page_box(&doc, b"CropBox"), Some(vec![167.0, 167.0, 833.0, 833.0]));
assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![200.0, 200.0, 800.0, 800.0]));
}
+108
View File
@@ -0,0 +1,108 @@
//! Printer marks and the page boxes: the marks must show inside the crop box, and running
//! again must not move the trim box or grow the page.
use pdfcraft_cos::{Dict, Document, Object};
use pdfcraft_edit::{PrinterMarks, add_printer_marks};
fn rect(r: [f64; 4]) -> Object {
Object::Array(r.iter().map(|v| Object::Real(*v)).collect())
}
/// One empty page with these boxes set on it.
fn page_with(boxes: &[(&[u8], [f64; 4])]) -> Document {
let mut doc = Document::new_empty();
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Parent".to_vec(), Object::Ref(pages));
for (key, r) in boxes {
page.set(key.to_vec(), rect(*r));
}
let page = doc.add(page);
doc.update_dict(pages, |d| {
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
d.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
doc
}
fn page_box(doc: &Document, key: &[u8]) -> Option<Vec<f64>> {
let page = pdfcraft_model::pages(doc).first().cloned()?;
let a = page.dict.get(key).map(|o| doc.resolve(o))?;
Some(a.as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64()).collect())
}
/// A crop box cut to the trim inside a large media box: the marks fit the media box but
/// not the crop box, so the crop box grows to show them and the trim box stays.
#[test]
fn printer_marks_widen_crop_inside_media() {
let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 1000.0, 1000.0]), (b"CropBox", [200.0, 200.0, 800.0, 800.0])]);
let marks = PrinterMarks::default();
let reach = marks.reach();
let widened = add_printer_marks(&mut doc, &[0], &marks, false).unwrap();
assert_eq!(widened, vec![0]);
assert_eq!(page_box(&doc, b"MediaBox"), Some(vec![0.0, 0.0, 1000.0, 1000.0]), "the media box already had room");
let crop = page_box(&doc, b"CropBox").unwrap();
assert_eq!(crop, vec![200.0 - reach, 200.0 - reach, 800.0 + reach, 800.0 + reach]);
assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![200.0, 200.0, 800.0, 800.0]));
}
/// No trim box given: it defaults to the crop box, which widening moves. Running again with
/// replace must keep the trim box and the page size from the first run.
#[test]
fn printer_marks_rerun_keeps_trim() {
let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]);
let marks = PrinterMarks::default();
add_printer_marks(&mut doc, &[0], &marks, false).unwrap();
let media = page_box(&doc, b"MediaBox");
let crop = page_box(&doc, b"CropBox");
assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 612.0, 792.0]));
assert_eq!(page_box(&doc, b"BleedBox"), Some(vec![0.0, 0.0, 612.0, 792.0]));
let widened = add_printer_marks(&mut doc, &[0], &marks, true).unwrap();
assert!(widened.is_empty(), "the page already has room");
assert_eq!(page_box(&doc, b"MediaBox"), media, "the page did not grow again");
assert_eq!(page_box(&doc, b"CropBox"), crop);
assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 612.0, 792.0]));
}
/// A trim box past the media box is clipped to it. Widening the media box must not let it
/// grow to what was written.
#[test]
fn printer_marks_keep_the_trim_box_in_effect() {
let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 100.0, 100.0]), (b"TrimBox", [-20.0, -20.0, 120.0, 120.0])]);
add_printer_marks(&mut doc, &[0], &PrinterMarks::default(), false).unwrap();
assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 100.0, 100.0]));
}
/// Line weights and room out of range are refused, not clamped, and nothing past the largest
/// page a PDF can have is written.
#[test]
fn printer_marks_refuse_numbers_out_of_range() {
let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]);
for line_weight in [-1.0, 0.0, 0.05, 5.0, 10_000.0, f64::NAN, f64::INFINITY] {
assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { line_weight, ..PrinterMarks::default() }, false).is_err(), "{line_weight}");
}
for room in [-1.0, 289.0, 1e9, f64::NAN, f64::INFINITY] {
assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { make_room: Some(room), ..PrinterMarks::default() }, false).is_err(), "{room}");
}
let mut big = page_with(&[(b"MediaBox", [0.0, 0.0, 14_400.0, 1000.0])]);
assert!(add_printer_marks(&mut big, &[0], &PrinterMarks::default(), false).is_err());
assert!(add_printer_marks(&mut big, &[0], &PrinterMarks { make_room: None, ..PrinterMarks::default() }, false).is_ok(), "left as it is");
}
/// Registration colour is the /All Separation, in the page's resources beside what it had.
#[test]
fn printer_marks_use_the_all_separation() {
let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]);
add_printer_marks(&mut doc, &[0], &PrinterMarks::default(), false).unwrap();
let page = pdfcraft_model::pages(&doc).swap_remove(0);
let res = doc.resolve(page.dict.get(b"Resources").unwrap());
let res = res.as_dict().unwrap();
assert!(res.get(b"Font").is_some());
let spaces = doc.resolve(res.get(b"ColorSpace").unwrap());
let cs = doc.resolve(spaces.as_dict().unwrap().get(b"PCReg").unwrap());
let cs = cs.as_array().unwrap();
assert_eq!(cs[..3], [Object::name("Separation"), Object::name("All"), Object::name("DeviceCMYK")]);
assert!(cs[3].as_dict().unwrap().int(b"FunctionType") == Some(2));
}
+1
View File
@@ -78,6 +78,7 @@ family (`sign`, `sign_with_timestamp`, `timestamp_document`, `embed_ltv`).
| `catalog` | Acrobat's "All tools" information architecture as data — groups, sections, icons, hues, and an `Availability` of `Ready`, `Planned(milestone)` or `Provider`. The UI renders what is *not* built yet, honestly |
| `actions` | PDF action dispatch (go-to, URI, named, set-layer-state, JavaScript) |
| `links` | Link creation, properties, and finding URLs in page text |
| `open_parameters` | A path's PDF open parameters (`manual.pdf#nameddest=Intro`, `#page=12`): the file to open and where to show it. The app and `doc_open` both read them |
| `js` | The bridge to `pdfcraft-js`. `forms` never depends on `js` directly; the engine injects it, so a build without JavaScript is possible |
| `xfa`, `ocr`, `compare`, `export` | Façades over the L3/L4 crates of the same names |
+2 -2
View File
@@ -409,9 +409,9 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
items: &[
item("Output preview", "layers", "prepress.output_preview", Planned("M11")),
item("Preflight", "file-check", "preflight.run", Planned("M11")),
item("Convert colors", "palette", "prepress.convert_colors", Planned("M11")),
item("Convert colours", "palette", "prepress.convert_colors", Ready),
item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready),
item("Add printer marks", "ruler", "prepress.marks", Planned("M11")),
item("Add printer marks", "ruler", "prepress.marks", Ready),
item("Fix hairlines", "minus", "prepress.hairlines", Ready),
],
}],
+13
View File
@@ -231,6 +231,16 @@ pub const COMMANDS: &[CommandSpec] = &[
// Split view (Acrobat's Window ▸ Split): the current document in two panes, or back to one;
// a tab's right-click menu also splits right or down with any tab.
c("view.split", "Split", VIEW, Some(Shortcut::cmd("\\")), Document, "columns-2"),
// A document can hide the toolbars, menu and panels (ISO 32000-2 Table 147); this brings them
// back for its tab, and with nothing hidden hides them.
c(
"view.toggle_interface",
"Show or hide toolbars and panels",
VIEW,
Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "F8" }),
Document,
"panel-left",
),
// 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.
@@ -406,6 +416,9 @@ pub const COMMANDS: &[CommandSpec] = &[
c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"),
c("page.split", "Split document…", PAGES, None, Assembly, "scissors"),
c("page.number", "Number pages…", PAGES, None, Assembly, "hash"),
c("prepress.marks", "Add printer marks…", None, None, Modification, "ruler"),
c("prepress.marks.remove", "Remove printer marks", None, None, Marks(crate::MarkKind::PrinterMarks), "ruler"),
c("prepress.convert_colors", "Convert colours…", None, None, Modification, "palette"),
c("prepress.hairlines", "Fix hairlines…", None, None, Modification, "minus"),
c("page.properties", "Page properties…", PAGES, None, Modification, "info"),
c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"),
+16 -5
View File
@@ -24,9 +24,12 @@ pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render:
fill_sign: s.fill_sign,
no_rotate: s.no_rotate,
points: s.points,
text_box: s.text_box,
popup: s.popup.map(|p| pdfcraft_render::Popup { rect: p.rect, open: p.open }),
})
.collect();
pdfcraft_render::link_replies(&mut list);
pdfcraft_render::fill_group_text(&mut list);
list
}
@@ -79,9 +82,10 @@ pub fn comment_type_name(subtype: &str) -> &str {
}
}
/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`.
/// [`comment_type_name`], telling replaced text, callouts, clouds and typewriter text apart by `/IT`.
pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str {
match a.intent.as_deref() {
Some("StrikeOutTextEdit" | "Replace") => "Replace Text",
Some("FreehandHighlight") if a.subtype == "Ink" => "Freehand Highlight",
Some("FreeTextCallout") => "Callout",
Some("PolygonCloud") => "Cloud",
@@ -106,19 +110,26 @@ pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: Su
}
// Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the
// direct parent, so following one level would leave the deeper replies out of the summary.
fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) {
// A group subordinate (`/RT /Group`, ISO 32000-2 §12.5.6.2) is part of the comment, not a
// reply: it is not written, and replies to it are written at its level, as the comment's.
// `depth` bounds the walk; `level` is the indent.
fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize, level: usize) {
use std::fmt::Write;
if depth > 64 {
return;
}
for r in children.get(&(parent.page, parent.index)).into_iter().flatten() {
let indent = " ".repeat(depth + 1);
if r.is_group_member() {
write_replies(out, children, r, depth + 1, level);
continue;
}
let indent = " ".repeat(level + 1);
let _ =
writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or(""));
for line in r.contents.as_deref().unwrap_or("").lines() {
let _ = writeln!(out, "{indent}{line}");
}
write_replies(out, children, r, depth + 1);
write_replies(out, children, r, depth + 1, level + 1);
}
}
let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new();
@@ -157,7 +168,7 @@ pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: Su
if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) {
let _ = writeln!(out, "{c}");
}
write_replies(&mut out, &children, a, 0);
write_replies(&mut out, &children, a, 0, 0);
out.push('\n');
}
out
+88 -2
View File
@@ -59,6 +59,10 @@ pub struct Document {
pub(crate) xfa_template: Option<Arc<pdfcraft_xfa::model::Template>>,
/// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]).
pub(crate) xfa_scripts_off: bool,
/// SOM paths of the XFA fields whose exit scripts ran with a value change ([`xfa::on_changes`])
/// since they last gained focus: their exit on losing focus has run already
/// ([`Session::field_focus`]).
pub(crate) xfa_exited: std::collections::HashSet<String>,
/// A content-only refresh keeps `info` from before the edit; an edit may have added or
/// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]).
pub(crate) fonts_stale: bool,
@@ -66,8 +70,9 @@ pub struct Document {
/// (also in `info.warnings`, kept there when the document is re-read).
pub xfa_warnings: Vec<String>,
/// What the last successful [`Session::apply`](crate::Session::apply) wants said (see
/// [`EditNotice`]): text its fonts can't draw, pages whose own backdrop stays off. The
/// interface shows it; tools return it.
/// [`EditNotice`]): text its fonts can't draw, pages whose own backdrop stays off, places
/// applied redactions left that still hold the removed text. The interface shows it; tools
/// return it.
pub edit_notices: Vec<EditNotice>,
}
@@ -82,6 +87,21 @@ pub enum EditNotice {
/// turning it on could paint over something (`pdfcraft_edit::backdrops_left_off`). They look
/// the same on white paper.
BackdropsLeftOff { pages: Vec<usize> },
/// Applying redactions left these places as they were, and they still hold the removed text
/// (bookmark titles, attachments, the comments that remain, XMP on other objects; see
/// [`RedactionElsewhere`](crate::RedactionElsewhere)). Only when one is counted, or the text
/// couldn't be searched for.
RedactionElsewhere(pdfcraft_redact::Elsewhere),
/// Applying redactions removed the document's XMP packet, which held the removed text and
/// said the file conforms to PDF/A or PDF/UA: each claim was written back in a packet of its
/// own, or lost (see [`RedactionIdentification`](crate::RedactionIdentification)).
RedactionIdentification(pdfcraft_redact::Identification),
}
/// The standards whose identification `id` has as `claim`: "PDF/A", "PDF/UA" or both, joined.
pub(crate) fn standards_with(id: &pdfcraft_redact::Identification, claim: pdfcraft_redact::Claim) -> Option<String> {
let names: Vec<&str> = [("PDF/A", id.pdfa), ("PDF/UA", id.pdfua)].into_iter().filter(|(_, c)| *c == claim).map(|(n, _)| n).collect();
(!names.is_empty()).then(|| names.join(" and "))
}
impl std::fmt::Display for EditNotice {
@@ -96,10 +116,50 @@ impl std::fmt::Display for EditNotice {
page_runs(pages)
)
}
EditNotice::RedactionElsewhere(e) => elsewhere_text(e, f),
EditNotice::RedactionIdentification(id) => {
use pdfcraft_redact::Claim;
write!(f, "the document's XMP metadata held the redacted text and was removed")?;
if let Some(kept) = standards_with(id, Claim::Kept) {
write!(f, "; its {kept} identification was written back on its own")?;
}
match standards_with(id, Claim::Lost) {
Some(lost) => write!(f, "; its {lost} identification couldn't be kept, so the file no longer says it conforms to {lost}"),
None => Ok(()),
}
}
}
}
}
/// [`EditNotice::RedactionElsewhere`] in words (English; the interface has its own wording).
fn elsewhere_text(e: &pdfcraft_redact::Elsewhere, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let count = |n: usize, one: &str, many: &str| match n {
0 => None,
1 => Some(format!("1 {one}")),
n => Some(format!("{n} {many}")),
};
let places: Vec<String> = [
count(e.bookmarks, "bookmark title", "bookmark titles"),
count(e.attachments, "attachment", "attachments"),
count(e.comments, "comment", "comments"),
count(e.xmp, "other metadata stream", "other metadata streams"),
]
.into_iter()
.flatten()
.collect();
let list = match places.split_last() {
None => "no place counted".to_string(),
Some((last, [])) => last.clone(),
Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
};
if e.unsearched {
write!(f, "the redacted text couldn't be searched for, so {list} may still hold it (Remove hidden information removes them)")
} else {
write!(f, "the redacted text also appears in {list}, which redaction doesn't change (Remove hidden information removes them)")
}
}
/// 0-based pages as 1-based runs: `[0, 1, 2, 5]` is "1-3, 6".
pub(crate) fn page_runs(pages: &[usize]) -> String {
let mut runs: Vec<(usize, usize)> = Vec::new();
@@ -186,6 +246,26 @@ pub(crate) fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos
}
impl Document {
/// The places the last edit's applied redactions left that still hold the removed text
/// ([`EditNotice::RedactionElsewhere`]), or `None` when there are none or the last edit
/// applied none.
pub fn redaction_elsewhere(&self) -> Option<pdfcraft_redact::Elsewhere> {
self.edit_notices.iter().find_map(|n| match n {
EditNotice::RedactionElsewhere(e) => Some(*e),
_ => None,
})
}
/// What became of the PDF/A and PDF/UA identification when the last edit's applied
/// redactions removed the document's XMP packet ([`EditNotice::RedactionIdentification`]),
/// or `None` when no packet that made such a claim was removed.
pub fn redaction_identification(&self) -> Option<pdfcraft_redact::Identification> {
self.edit_notices.iter().find_map(|n| match n {
EditNotice::RedactionIdentification(id) => Some(*id),
_ => None,
})
}
/// How the document opens (Document Properties ▸ Initial View).
/// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be
/// read for editing).
@@ -419,10 +499,16 @@ impl Document {
self.undo_label().map(Label::as_str)
}
/// What Redo would redo, in English, when there is something to redo.
pub fn can_redo(&self) -> Option<&str> {
self.redo_label().map(Label::as_str)
}
/// How many steps Undo can take back: a step still at this depth is still the last one.
pub fn undo_depth(&self) -> usize {
self.editor.as_ref().map_or(0, |e| e.undo.len())
}
/// What Undo would undo, in parts, for an interface that says it in another language.
pub fn undo_label(&self) -> Option<&Label> {
self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l)
+31 -3
View File
@@ -5,9 +5,9 @@ use std::sync::Arc;
use crate::{
AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, Hairlines, HeaderFooter, Hidden,
ImageEdit, InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget,
PageBox, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file,
embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa,
unrestricted, xfa, xfa_after_data_change,
PageBox, PrinterMarks, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image,
decode_data_file, embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster,
open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
};
mod labels;
@@ -160,12 +160,14 @@ pub enum Edit {
/// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt).
/// Any other `rect` is a box: the picture is scaled to fit inside it, keeping its shape,
/// and centred in it (#4077).
/// `line` is a text line under the picture, already filled in ([`Session::stamp_line`](crate::Session::stamp_line)).
AddCustomStamp {
page: usize,
rect: [f64; 4],
name: String,
file: MarkFile,
author: String,
line: Option<String>,
},
/// Rich Media ▸ Add 3D: a U3D or PRC `model` as a 3D annotation in `rect` on `page`, named
/// `name` (its alternate description). Its poster is the picture in `poster` (a PDF page or an
@@ -243,6 +245,14 @@ pub enum Edit {
index: usize,
points: Vec<[f64; 2]>,
},
/// Open, close or move a comment's pop-up window (`rect` in user space; a comment without a
/// pop-up gets one).
SetAnnotationPopup {
page: usize,
index: usize,
rect: Option<[f64; 4]>,
open: Option<bool>,
},
/// Resize a rectangle, oval or text box.
ResizeAnnotation {
page: usize,
@@ -349,6 +359,14 @@ pub enum Edit {
},
/// Document Properties ▸ Initial View (and the language and binding).
SetInitialView(Box<InitialView>),
/// Print production ▸ Add printer marks: trim and bleed marks, registration targets, colour
/// bars and page information outside the trim box, as a page mark; the page may be widened
/// to make room (`settings.make_room`), so the page geometry is read again.
AddPrinterMarks {
pages: Vec<usize>,
settings: PrinterMarks,
replace: bool,
},
/// Print production ▸ Fix hairlines: strokes narrower than `settings.narrower_than` points on
/// the page (after every transformation; zero-width lines always) get `settings.width`.
FixHairlines {
@@ -367,6 +385,16 @@ pub enum Edit {
DeleteAttachment {
key: String,
},
/// Print production ▸ Convert colours: every colour on the pages in grayscale, RGB or CMYK.
ConvertColours {
pages: Vec<usize>,
settings: pdfcraft_edit::ConvertColours,
},
/// Change or clear only `/Desc` on a document-level attachment's file specification.
EditAttachmentDescription {
key: String,
description: Option<String>,
},
/// 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
+11
View File
@@ -31,6 +31,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Insert pages from {name} — fields renamed",
"Attach {name}",
"Delete attachment {key}",
"Edit attachment description",
"Add bookmark",
"Rename bookmark",
"Delete bookmark",
@@ -87,6 +88,9 @@ pub const EDIT_LABELS: &[&str] = &[
"Unlock comment",
"Move comment",
"Reshape comment",
"Open comment pop-up",
"Close comment pop-up",
"Move comment pop-up",
"Resize comment",
"Change comment properties",
"Fill in {name}",
@@ -232,6 +236,9 @@ impl Edit {
Edit::LockAnnotation { .. } => Label::of("Unlock comment"),
Edit::MoveAnnotation { .. } => Label::of("Move comment"),
Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"),
Edit::SetAnnotationPopup { rect: Some(_), .. } => Label::of("Move comment pop-up"),
Edit::SetAnnotationPopup { open: Some(true), .. } => Label::of("Open comment pop-up"),
Edit::SetAnnotationPopup { .. } => Label::of("Close comment pop-up"),
Edit::ResizeAnnotation { .. } => Label::of("Resize comment"),
Edit::StyleAnnotation { .. }
| Edit::FillAnnotation { .. }
@@ -253,6 +260,8 @@ impl Edit {
Edit::FixHairlines { .. } => Label::of("Fix hairlines"),
Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()),
Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()),
Edit::ConvertColours { .. } => Label::of("Convert colours"),
Edit::EditAttachmentDescription { .. } => Label::of("Edit attachment description"),
Edit::AutoTagText => Label::of("Automatically tag text"),
Edit::SetAltText { .. } => Label::of("Set alternate text"),
Edit::AutoTagFormFields => Label::of("Tag form fields"),
@@ -301,6 +310,8 @@ impl Edit {
Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"),
Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"),
Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"),
Edit::RemoveMarks { kind: MarkKind::PrinterMarks } => Label::of("Remove printer marks"),
Edit::AddPrinterMarks { .. } => Label::of("Add printer marks"),
Edit::AddText { .. } => Label::of("Add text"),
Edit::AddImage { .. } => Label::of("Add image"),
Edit::UpdateContent { .. } => Label::of("Edit content"),
+97 -15
View File
@@ -62,6 +62,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::LockAnnotation { .. }
| Edit::MoveAnnotation { .. }
| Edit::ReshapeAnnotation { .. }
| Edit::SetAnnotationPopup { .. }
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
| Edit::FillAnnotation { .. }
@@ -91,6 +92,11 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
Err(EditError::NotPermitted("changing security"))
}
}
// Converting the appearances of comments and form fields changes them too, which
// content changes alone do not allow.
Edit::ConvertColours { settings, .. } if settings.annotations && !(p.modify() && p.annotate()) => {
Err(EditError::NotPermitted(if p.modify() { "comments" } else { "changes to the document" }))
}
// Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted
// document it is a change of security (#3921).
Edit::ConvertPdfA { .. } => {
@@ -102,6 +108,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
}
Edit::SetInfo { .. }
| Edit::AddHeaderFooter { .. }
| Edit::AddPrinterMarks { .. }
| Edit::AddWatermark { .. }
| Edit::AddBackground { .. }
| Edit::RemoveMarks { .. }
@@ -137,6 +144,8 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::FixHairlines { .. }
| Edit::AddAttachment { .. }
| Edit::DeleteAttachment { .. }
| Edit::ConvertColours { .. }
| Edit::EditAttachmentDescription { .. }
| Edit::AutoTagText
| Edit::SetAltText { .. }
| Edit::AutoTagFormFields
@@ -220,12 +229,16 @@ pub(crate) struct EditCtx {
pub(crate) xfa_datasets: Option<pdfcraft_cos::ObjRef>,
/// A form script ran too long and was abandoned: the document's scripts go off.
pub(crate) xfa_ran_away: bool,
/// An XFA enter or exit event whose scripts changed nothing: no edit to keep.
pub(crate) xfa_unchanged: bool,
/// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]).
pub(crate) xfa_time_scale: u32,
/// The edit took the XFA out of the form: what went with it, for the document's warnings.
pub(crate) xfa_removed: Vec<String>,
/// Pages in the document, for `xfa.layout.pageCount()`.
pub(crate) pages: usize,
/// Whether the viewer highlights form fields, for `app.runtimeHighlight`.
pub(crate) highlight: bool,
pub(crate) date: Option<String>,
/// Today in local time, for date tokens.
pub(crate) today: (i64, u32, u32),
@@ -239,12 +252,34 @@ pub(crate) struct EditCtx {
/// The overlay streams the edit's applied redactions added, which alone the renderer's check
/// leaves out.
pub(crate) redaction_overlays: Vec<pdfcraft_cos::ObjRef>,
/// What the edit's applied redactions left as it was that still holds the removed text.
pub(crate) redaction_elsewhere: Option<pdfcraft_redact::Elsewhere>,
/// What became of the PDF/A and PDF/UA identification when the edit's applied redactions
/// removed the catalog's XMP packet.
pub(crate) redaction_identification: pdfcraft_redact::Identification,
/// What [`Edit::PrepareText`] prepared, repaired and left grouped, for
/// [`Session::prepare_text`](crate::Session::prepare_text).
pub(crate) prepared: Option<pdfcraft_edit::text::forms::Preparation>,
}
impl EditCtx {
/// The field highlight (`app.runtimeHighlight`) the edit's next script reads: the field
/// scripts' runner holds it while there is one (its scripts change it there), else the edit.
pub(crate) fn highlight(&self) -> bool {
self.js.as_ref().map_or(self.highlight, |js| js.doc.highlight)
}
/// A script changed the field highlight: both script runtimes of the edit read the new one,
/// so a later edit of a batch sees it whichever kind of script it runs.
pub(crate) fn set_highlight(&mut self, on: bool) {
self.highlight = on;
if let Some(js) = self.js.as_mut() {
js.doc.highlight = on;
}
}
/// The context for one edit: `now` (the session's clock) dates it, and with `salt` seeds the
/// ids it makes.
pub(crate) fn new(now: Option<i64>, salt: u64) -> Self {
let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt;
if let Some(t) = now {
@@ -261,9 +296,11 @@ impl EditCtx {
xfa_out: Default::default(),
xfa_datasets: None,
xfa_ran_away: false,
xfa_unchanged: false,
xfa_time_scale: 1,
xfa_removed: Vec::new(),
pages: 0,
highlight: false,
date: now.map(pdfcraft_cos::pdf_date),
today: (1970, 1, 1),
seed,
@@ -271,6 +308,8 @@ impl EditCtx {
rng: getrandom::fill,
renamed_fields: Vec::new(),
redaction_overlays: Vec::new(),
redaction_elsewhere: None,
redaction_identification: pdfcraft_redact::Identification::default(),
prepared: None,
}
}
@@ -381,13 +420,10 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
if let Some((added_idx, added_item)) = matched_added
&& let AddedContent::Image(ai) = added_item.content.clone()
{
// Page visibility alone is not a crop: dragging an added image over the page edge
// must not discard its editable added-item identity on the next resize.
let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6)
|| img.visible_rect.is_some_and(|v| {
(v[0] - img.rect[0]).abs() > 1e-4
|| (v[1] - img.rect[1]).abs() > 1e-4
|| (v[2] - img.rect[2]).abs() > 1e-4
|| (v[3] - img.rect[3]).abs() > 1e-4
});
|| img.frame_rect().is_none_or(|frame| frame.iter().zip(img.rect).any(|(a, b)| (a - b).abs() > 1e-4));
let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _])
if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5)));
let should_promote = has_active_crop
@@ -559,9 +595,12 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
}
note_skipped(doc, &report);
}
Edit::AddCustomStamp { page, rect, name, file, author } => {
Edit::AddCustomStamp { page, rect, name, file, author, line } => {
let src = mark_source(doc, file)?;
let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0));
let line = line.as_deref().map(str::trim).filter(|l| !l.is_empty()).map(|l| l.chars().take(crate::MAX_STAMP_LINE).collect::<String>());
let picture = (src.size.0.max(1.0), src.size.1.max(1.0));
// A text line adds its band under the picture, and widens the stamp to fit (#1741).
let (sw, sh) = pdfcraft_annot::custom_stamp_size(picture, line.as_deref());
let natural = rect[2] == rect[0] && rect[3] == rect[1];
let rotation = pdfcraft_organize::page_rotation(doc, *page)?;
// The picture as displayed: a quarter turn swaps its sides in user space.
@@ -573,7 +612,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
} else {
fit_picture(*rect, dw, dh)
};
let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) };
let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: picture, line };
let style = pdfcraft_annot::Style::default_for(&shape);
let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() };
let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?;
@@ -610,8 +649,25 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?;
}
Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?,
Edit::ConvertColours { pages, settings } => {
let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, t: pdfcraft_edit::ColourTarget| {
let target = match t {
pdfcraft_edit::ColourTarget::Gray => pdfcraft_optimize::ImageTarget::Gray,
pdfcraft_edit::ColourTarget::Rgb => pdfcraft_optimize::ImageTarget::Rgb,
pdfcraft_edit::ColourTarget::Cmyk => pdfcraft_optimize::ImageTarget::Cmyk,
};
pdfcraft_optimize::recolour_image(doc, s, target)
};
let done = pdfcraft_edit::convert_colours(doc, pages, settings, &mut recolour)?;
if done.colours == 0 && done.images == 0 {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("nothing on those pages needed converting".into())));
}
}
Edit::EditAttachmentDescription { key, description } => {
pdfcraft_organize::set_attachment_description(doc, key, description.as_deref())?;
}
Edit::ReplaceText { page, quads, text, author, strike, caret } => {
// One `/NM` each: the strikeout is grouped under the caret, and XFDF names that link by
// One `/NM` each: the strikeout is grouped with the caret, and XFDF names that link by
// the caret's `/NM` (#2666).
let (strike_meta, caret_meta) = (cx.meta(), cx.meta());
pdfcraft_annot::add_text_replacement(
@@ -634,6 +690,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?,
Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?,
Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?,
Edit::SetAnnotationPopup { page, index, rect, open } => pdfcraft_annot::set_popup(doc, *page, *index, *rect, *open, &cx.meta())?,
Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?,
Edit::StyleAnnotation { page, index, color, opacity, width, endings } => {
pdfcraft_annot::set_style(
@@ -670,12 +727,26 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
},
Edit::XfaEvent { som, activity } => {
let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?;
let mut run =
xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale };
let ran = xfa::on_event(doc, &tpl, som, activity, &mut run);
let highlight = cx.highlight();
let mut run = xfa::XfaRun {
page_count: cx.pages,
out: &mut cx.xfa_out,
datasets: cx.xfa_datasets,
ran_away: false,
highlight,
time_scale: cx.xfa_time_scale,
};
let ran = if matches!(activity.as_str(), "enter" | "exit") {
xfa::on_focus(doc, &tpl, som, activity, &mut run).map(|changes| changes.is_empty())
} else {
xfa::on_event(doc, &tpl, som, activity, &mut run).map(|()| false)
};
cx.xfa_datasets = run.datasets;
cx.xfa_ran_away |= run.ran_away;
ran.map_err(EditError::Invalid)?;
// The next edit of a batch reads the highlight this one's scripts set.
let on = run.highlight;
cx.set_highlight(on);
cx.xfa_unchanged = ran.map_err(EditError::Invalid)?;
}
Edit::SetFieldImage { name, image } => {
let (img, _) = embed_image(doc, name, image)?;
@@ -803,6 +874,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::LayoutTableToBlocks { object } => {
a11y::layout_table_to_blocks(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?;
}
Edit::AddPrinterMarks { pages, settings, replace } => {
pdfcraft_edit::add_printer_marks(doc, pages, settings, *replace)?;
}
Edit::AddHeaderFooter { pages, settings, replace } => {
let date = cx.today;
pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?;
@@ -877,7 +951,10 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
pdfcraft_edit::add_content(doc, *page, &content)?;
}
Edit::ApplyRedactions { pages } => {
cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays;
let report = pdfcraft_redact::apply(doc, pages.as_deref())?;
cx.redaction_overlays = report.overlays;
cx.redaction_elsewhere = Some(report.still_holding);
cx.redaction_identification = report.identification;
}
Edit::ClearRedactions => {
pdfcraft_redact::clear_marks(doc, None)?;
@@ -994,6 +1071,11 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
doc.remove_encryption();
}
Edit::Batch { edits, .. } => {
// A form-script event runs on its own: in a batch, its scripts and those the batch's
// value edits run afterwards (change, exit) would interleave, an exit running twice.
if edits.iter().any(|e| matches!(e, Edit::XfaEvent { .. })) {
return Err(EditError::Invalid("a form script event can't be part of a batch of edits".into()));
}
for e in edits {
run_edit(doc, e, cx)?;
}
+4 -1
View File
@@ -47,8 +47,9 @@ pub(crate) fn uses_scripts(edit: &Edit) -> bool {
pub(crate) fn scope_of(edit: &Edit) -> Scope {
match edit {
Edit::SetInfo { .. } => Scope::Metadata,
Edit::AddPrinterMarks { .. } => Scope::Full,
// The Attachments list is read from the whole document.
Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full,
Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } | Edit::EditAttachmentDescription { .. } => Scope::Full,
// A file attachment also changes the Attachments list.
Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full,
Edit::AddMeasurement(_)
@@ -66,6 +67,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
| Edit::EraseInk { .. }
| Edit::MoveAnnotation { .. }
| Edit::ReshapeAnnotation { .. }
| Edit::SetAnnotationPopup { .. }
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
| Edit::FillAnnotation { .. }
@@ -96,6 +98,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
| Edit::AddWatermark { .. }
| Edit::AddBackground { .. }
| Edit::RemoveMarks { .. }
| Edit::ConvertColours { .. }
| Edit::FixHairlines { .. }
// Recognized text is an invisible text layer stamped on its page, with its font in the
// page's resources (#6310): a full re-inspection made applying one word cost seconds on
+149 -9
View File
@@ -11,6 +11,15 @@ pub use pdfcraft_js::{Limits, Message, MessageButtons, MessageIcon, Outcome, Pan
use crate::{DocId, Edit, EditError, Session};
/// A field gaining or losing focus ([`Session::field_focus`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FieldFocus {
/// The field gained focus: its `enter` scripts run.
Enter,
/// The field lost focus: its `exit` scripts run.
Exit,
}
/// What scripts produced for the viewer since it last looked.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct JsOutput {
@@ -72,13 +81,13 @@ impl JsOutput {
}
/// Adds what a field's own script produced: its alerts, console lines, errors and requests,
/// except a request to show a side panel or open Save As, which is noted on the console
/// and dropped.
/// except a request to show a side panel, open Save As, or open or save an attached file,
/// which is noted on the console and dropped.
fn absorb(&mut self, o: &Outcome) {
self.alert(o.alerts.iter().cloned());
self.say(o.console.iter().cloned());
// A field's own scripts (keystroke, format, validate, calculate) run unasked: only a
// button's may show or hide a side panel, or open Save As.
// button's may show or hide a side panel, open Save As, or open or save an attached file.
// The warnings go through `say` like any other console line, so they count against the
// line and byte caps instead of growing the console past them.
let mut notes = Vec::new();
@@ -86,6 +95,9 @@ impl JsOutput {
match r {
Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()),
Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()),
Request::OpenAttachment { name, .. } => {
notes.push(format!("A field script asked to open the attached file {name}; only a button may open an attached file"))
}
r => self.requests.push(r.clone()),
}
}
@@ -255,11 +267,18 @@ pub struct JsRunner {
impl JsRunner {
/// A runner for `cos`: what scripts read about the document (pages, info, whether it is
/// an XFA form) and its document-level scripts, read once.
pub fn new(cos: &pdfcraft_cos::Document, file_name: &str) -> JsRunner {
/// an XFA form) and its document-level scripts, read once. `highlight`: whether the viewer
/// highlights form fields (`app.runtimeHighlight`).
pub fn new(cos: &pdfcraft_cos::Document, file_name: &str, highlight: bool) -> JsRunner {
let xfa = pdfcraft_xfa::xfa_shape(cos).is_some();
let doc =
pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos), xfa };
let doc = pdfcraft_js::DocInfo {
file_name: file_name.to_string(),
num_pages: pdfcraft_model::pages(cos).len(),
page: 0,
info: info(cos),
highlight,
xfa,
};
// Once XFA owns the form, the PDF container's scripts belong to its fallback view.
// Keep them in the file and in Document JavaScripts, but do not initialize an
// AcroForm context with them: a viewer-compatibility alert would be misleading.
@@ -270,6 +289,14 @@ impl JsRunner {
}
}
/// The field highlight the last of `requests` that sets it asks for (`app.runtimeHighlight`).
fn last_highlight(requests: &[Request]) -> Option<bool> {
requests.iter().rev().find_map(|r| match r {
Request::Highlight(on) => Some(*on),
_ => None,
})
}
/// The Info dictionary as `this.info` keys (lower-case).
fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> {
let Some(d) = cos.trailer().get(b"Info").map(|i| cos.resolve(i)).and_then(|i| i.as_dict().cloned()) else { return Vec::new() };
@@ -320,6 +347,10 @@ impl JsRunner {
if log {
self.output.absorb(&o);
}
// The edit's later scripts read the field highlight this one set.
if let Some(on) = last_highlight(&o.requests) {
self.doc.highlight = on;
}
if o.error.is_some() {
return None;
}
@@ -355,21 +386,124 @@ impl crate::Document {
}
}
/// When a signature field's XFA signing scripts run ([`Session::run_sign_event`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SignEvent {
/// Just before the field is signed (`preSign`): what it changes is signed too.
Before,
/// Once the field is signed (`postSign`).
After,
}
impl SignEvent {
/// The template's name for the event.
pub fn activity(self) -> &'static str {
match self {
SignEvent::Before => "preSign",
SignEvent::After => "postSign",
}
}
}
impl Session {
/// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript (on by default).
pub fn set_javascript(&mut self, on: bool) {
self.js_off = !on;
}
/// Whether JavaScript is on (Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript).
pub fn javascript(&self) -> bool {
!self.js_off
}
/// Whether the viewer highlights form fields (Highlight fields), which forms' scripts read as
/// `app.runtimeHighlight`. A script that changes it asks for [`Request::Highlight`]; the
/// viewer then calls this with the new setting.
pub fn set_field_highlight(&mut self, on: bool) {
self.field_highlight = on;
}
/// What scripts printed or asked for since the last call (alerts, console, print, …).
pub fn take_js_output(&mut self, id: DocId) -> JsOutput {
self.docs.iter_mut().find(|d| d.id == id).map(|d| std::mem::take(&mut d.js_output)).unwrap_or_default()
}
/// The last field-highlight change (`app.runtimeHighlight`) scripts asked for in document
/// `id`, taken out of its waiting output; the rest stays for [`Session::take_js_output`].
pub fn take_js_highlight(&mut self, id: DocId) -> Option<bool> {
let doc = self.docs.iter_mut().find(|d| d.id == id)?;
let mut last = None;
doc.js_output.requests.retain(|r| match r {
Request::Highlight(on) => {
last = Some(*on);
false
}
_ => true,
});
last
}
/// Widget `widget` of field `name` in document `id` gained or lost focus: on a laid-out XFA form,
/// run the field's `enter` or `exit` scripts, as one undoable edit when they change something
/// (and none when they don't). An exit that the field's new value already ran (the value's
/// change runs it) isn't run a second time. Returns whether the document changed; what the
/// scripts showed or asked for is in [`Session::take_js_output`].
pub fn field_focus(&mut self, id: DocId, name: &str, widget: usize, focus: FieldFocus) -> Result<bool, EditError> {
if self.js_off {
return Ok(false);
}
let doc = self.doc_mut(id)?;
let Some(tpl) = doc.xfa_template.clone().filter(|t| pdfcraft_xfa::has_focus_scripts(t)) else { return Ok(false) };
let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Ok(false) };
let Some(som) = doc.form.iter().find(|f| f.name == name).and_then(|f| crate::xfa::widget_som(cos, f, widget)) else { return Ok(false) };
let activity = match focus {
FieldFocus::Enter => "enter",
FieldFocus::Exit => "exit",
};
if !crate::xfa::has_event(cos, &tpl, &som, activity) {
doc.xfa_exited.remove(&som);
return Ok(false);
}
// Gaining focus starts afresh; losing it runs the exit unless the change just did.
if doc.xfa_exited.remove(&som) && focus == FieldFocus::Exit {
return Ok(false);
}
let before = doc.generation;
self.apply(id, Edit::XfaEvent { som, activity: activity.into() })?;
Ok(self.get(id).is_some_and(|d| d.generation != before))
}
/// Run signature field `field`'s XFA signing scripts for `event` in document `id`: `preSign`
/// before it is signed, `postSign` after, as one undoable step, as a form's signing events run
/// (DA 31's dates the signature and locks the date). `Ok(false)`, with nothing changed, when
/// there are none: not a laid-out XFA form, JavaScript off, no such field, or no such script.
/// What the scripts said or asked for is in [`Session::take_js_output`].
pub fn run_sign_event(&mut self, id: DocId, field: &str, event: SignEvent) -> Result<bool, EditError> {
if self.get(id).is_none() {
return Err(EditError::NoDocument);
}
let Some(som) = self.sign_event_som(id, field, event) else { return Ok(false) };
self.apply(id, Edit::XfaEvent { som, activity: event.activity().into() })?;
Ok(true)
}
/// Whether signature field `field` of document `id` has XFA signing scripts for `event` that
/// [`Session::run_sign_event`] would run, without running them.
pub fn sign_event_scripted(&self, id: DocId, field: &str, event: SignEvent) -> bool {
self.sign_event_som(id, field, event).is_some()
}
/// The SOM path of signature field `field` when it has scripts for `event` to run.
fn sign_event_som(&self, id: DocId, field: &str, event: SignEvent) -> Option<String> {
if self.js_off {
return None;
}
let doc = self.get(id)?;
let (tpl, editor) = (doc.xfa_template.as_ref()?, doc.editor.as_ref()?);
let f = doc.form.iter().find(|f| f.name == field)?;
crate::xfa::scripted_som(&editor.cos, tpl, f, event.activity())
}
/// Run `script` in document `id`, for a button (`target`: the field, event "Mouse Up") or
/// the console (no target). Field changes and `resetForm` are applied as one undoable step;
/// everything else the script asked for is returned.
@@ -379,10 +513,12 @@ impl Session {
}
let doc = self.get(id).ok_or(EditError::NoDocument)?;
let cos = doc.editor.as_ref().map(|e| e.cos.clone()).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
// A button PdfCraft generated from an XFA template runs its XFA click script.
// A button PdfCraft generated from an XFA template runs its XFA click script (the app runs
// click scripts for buttons only, so other fields' click events don't run here either).
if let Some(t) = target
&& let Some(tpl) = doc.xfa_template.clone()
&& let Some(field) = doc.form.iter().find(|f| f.name == t)
&& field.kind == crate::FormFieldKind::PushButton
&& let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field)
{
// The click returns only the output of its own edit; output from opening or from earlier edits stays.
@@ -396,7 +532,7 @@ impl Session {
..Default::default()
});
}
let runner = JsRunner::new(&cos, &doc.name);
let runner = JsRunner::new(&cos, &doc.name, self.field_highlight);
let fields = pdfcraft_forms::fields(&cos);
let states: Vec<_> = fields.iter().map(|f| field_state(&cos, f)).collect();
let event = match target {
@@ -414,6 +550,10 @@ impl Session {
})
.collect();
o.requests.retain(|r| !matches!(r, Request::Reset(_)));
// The calculations the script's changes set off read the field highlight it asked for.
if let Some(on) = last_highlight(&o.requests) {
self.field_highlight = on;
}
let mut edits = Vec::new();
if !changes.is_empty() {
edits.push(Edit::ApplyScriptChanges { changes });
+11 -4
View File
@@ -40,6 +40,7 @@ pub mod model3d;
mod object_clip;
pub mod ocr;
mod open;
pub mod open_parameters;
pub mod optimizer;
mod page_filter;
mod pictures;
@@ -96,8 +97,8 @@ pub use pdfcraft_edit::added::{
};
pub use pdfcraft_edit::paragraph_align;
pub use pdfcraft_edit::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, Hairlines, HairlinesFixed,
HeaderFooter, Ink as TextInk, MarkKind, Watermark,
Added, AddedImage, AddedText, Align as TextAlign, Background, ColourTarget, ColoursConverted, Content as AddedContent, ConvertColours,
Family as FontFamily, Hairlines, HairlinesFixed, HeaderFooter, Ink as TextInk, MarkKind, PrinterMarks, Watermark,
};
pub use pdfcraft_forms::{
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
@@ -120,14 +121,16 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr
pub use pdfcraft_annot::appearance as annot_text;
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle, LinkZoom};
pub use pdfcraft_annot::{
Activation3d, AppearanceCache, AttachIcon, BorderShape, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont,
OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad,
Activation3d, AppearanceCache, AttachIcon, BorderShape, CALLOUT_KNEE_LINE, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon,
OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, Side as CalloutSide, StampGroup, StampKind, Style, callout_attach,
callout_knee, callout_side, rect_quad,
};
pub use pdfcraft_annot::{MAX_MODEL_BYTES as MAX_3D_MODEL_BYTES, ModelFormat as Model3dFormat};
pub use pdfcraft_forms::detect;
pub use pdfcraft_optimize as optimize;
pub use pdfcraft_preflight as pdfa;
pub use pdfcraft_print as print;
pub use pdfcraft_redact::Elsewhere as RedactionElsewhere;
pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet};
pub use pdfcraft_redact::patterns::{
MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list,
@@ -135,6 +138,7 @@ pub use pdfcraft_redact::patterns::{
pub use pdfcraft_redact::redacted_file_name;
pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden};
pub use pdfcraft_redact::user_regex::{UserRegex as RedactRegex, VALIDATORS as REDACT_VALIDATORS, Validator as RedactValidator};
pub use pdfcraft_redact::{Claim as RedactionClaim, Identification as RedactionIdentification};
pub use pdfcraft_sign as sign;
pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, StatusCaptions, TrustStore};
pub use pdfcraft_xfdf::Format as DataFormat;
@@ -189,5 +193,8 @@ fn display_config(config: &pdfcraft_render::RenderConfig) -> pdfcraft_render::Re
config.clone().on_screen()
}
/// How long a custom stamp's text line may be, in characters (#1741).
pub const MAX_STAMP_LINE: usize = 200;
#[cfg(test)]
mod tests;
+14
View File
@@ -18,6 +18,7 @@ pub struct OpenContext {
now: Option<i64>,
js_off: bool,
trust: Arc<TrustStore>,
highlight: bool,
captions: Arc<StatusCaptions>,
xfa_time_scale: u32,
}
@@ -39,6 +40,14 @@ struct DocumentOpenData<'a> {
}
impl OpenContext {
/// This context with Highlight fields `on`, which the document's scripts read as
/// `app.runtimeHighlight` while it opens. A new document opens with the remembered setting
/// while the session keeps the one of the document in front, which its scripts read.
pub fn with_highlight(mut self, on: bool) -> Self {
self.highlight = on;
self
}
/// Prepare a document without mutating the session that supplied this context.
pub fn prepare_open(
&self,
@@ -60,6 +69,7 @@ impl Session {
now: self.now(),
js_off: self.js_off,
trust: self.trust.clone(),
highlight: self.field_highlight,
captions: self.captions.clone(),
xfa_time_scale: self.xfa_time_scale.unwrap_or(1),
}
@@ -100,6 +110,8 @@ impl Session {
Ok(self.commit_open(prepared))
}
/// What [`OpenContext::prepare_open`] runs inside its panic guard: read, decrypt and lay out
/// the document, and run an XFA form's opening scripts.
fn prepare_open_unguarded(
context: &OpenContext,
name: String,
@@ -210,6 +222,7 @@ impl Session {
out: &mut script_output,
datasets: None,
ran_away: false,
highlight: context.highlight,
time_scale: context.xfa_time_scale,
};
let changes = xfa::on_open(&mut work, &tpl, &mut run);
@@ -371,6 +384,7 @@ impl Session {
xfa,
xfa_template: None,
xfa_scripts_off: false,
xfa_exited: Default::default(),
fonts_stale: false,
xfa_warnings: Vec::new(),
edit_notices: Vec::new(),
+101
View File
@@ -0,0 +1,101 @@
//! PDF open parameters on a path (#3425): help systems and other programs start a PDF reader with
//! `manual.pdf#nameddest=Intro` or `manual.pdf#page=12` to show one place in a document. The
//! parameters follow Adobe's public "Parameters for Opening PDF Files": `name=value` pairs after a
//! `#`, separated by `&` or `#`. PdfCraft reads `page` (1-based) and `nameddest`, and, as browsers'
//! PDF viewers do, a fragment without `=` as a destination name. Other parameters (`zoom`, `view`,
//! `pagemode`, `search`, …) are ignored, and the file still opens.
use pdfcraft_render::{DestView, RemoteDestination};
/// How many `#` of one path are tried as the end of its file name.
const MAX_CANDIDATES: usize = 32;
/// The file `path` names and the destination its open parameters ask for, when `path` is not a
/// file itself but what comes before one of its `#` is. The first such `#` counts, so a folder or
/// file name may contain `#` too. `None`: open `path` as it is.
pub fn split(path: &str, is_file: impl Fn(&str) -> bool) -> Option<(String, RemoteDestination)> {
if is_file(path) {
return None;
}
let (file, fragment) = path.match_indices('#').take(MAX_CANDIDATES).find_map(|(i, _)| {
// `#` is one byte, so `i` and `i + 1` are char boundaries.
let file = path.get(..i)?;
is_file(file).then(|| (file, path.get(i + 1..).unwrap_or_default()))
})?;
Some((file.to_string(), destination(fragment)))
}
/// The destination of a fragment's parameters; the last `page` or `nameddest` wins, as each
/// parameter is applied in turn. A page before the first, or one that isn't a number, is ignored.
fn destination(fragment: &str) -> RemoteDestination {
if !fragment.contains('=') {
return if fragment.is_empty() { RemoteDestination::Default } else { RemoteDestination::Named(fragment.to_string()) };
}
let mut destination = RemoteDestination::Default;
for (key, value) in fragment.split(['&', '#']).filter_map(|p| p.split_once('=')) {
if key.eq_ignore_ascii_case("page") {
if let Some(page) = value.trim().parse::<usize>().ok().and_then(|n| n.checked_sub(1)) {
destination = RemoteDestination::Page { page, view: DestView::Top };
}
} else if key.eq_ignore_ascii_case("nameddest") && !value.is_empty() {
destination = RemoteDestination::Named(value.to_string());
}
}
destination
}
#[cfg(test)]
mod tests {
use super::*;
fn files(names: &'static [&'static str]) -> impl Fn(&str) -> bool {
move |p| names.contains(&p)
}
fn page(page: usize) -> RemoteDestination {
RemoteDestination::Page { page, view: DestView::Top }
}
fn named(name: &str) -> RemoteDestination {
RemoteDestination::Named(name.to_string())
}
#[test]
fn a_named_destination_or_page_after_the_file_name_is_split_off() {
let manual = files(&[r"C:\Help\manual.pdf"]);
// #3425: DIgSILENT PowerFactory's F1 help.
assert_eq!(split(r"C:\Help\manual.pdf#nameddest=Load Flow", &manual), Some((r"C:\Help\manual.pdf".into(), named("Load Flow"))));
assert_eq!(split(r"C:\Help\manual.pdf#page=12", &manual), Some((r"C:\Help\manual.pdf".into(), page(11))));
// A bare fragment names a destination, as in a browser.
assert_eq!(split(r"C:\Help\manual.pdf#intro", &manual), Some((r"C:\Help\manual.pdf".into(), named("intro"))));
}
#[test]
fn several_parameters_apply_in_turn_and_unknown_ones_are_ignored() {
let pdf = files(&["/docs/a.pdf"]);
assert_eq!(split("/docs/a.pdf#page=3&zoom=200,0,0", &pdf), Some(("/docs/a.pdf".into(), page(2))));
assert_eq!(split("/docs/a.pdf#zoom=50#nameddest=x", &pdf), Some(("/docs/a.pdf".into(), named("x"))));
assert_eq!(split("/docs/a.pdf#nameddest=x&page=4", &pdf), Some(("/docs/a.pdf".into(), page(3))));
assert_eq!(split("/docs/a.pdf#PAGE=2", &pdf), Some(("/docs/a.pdf".into(), page(1))));
// The file opens at its default view when nothing can be applied.
for odd in
["/docs/a.pdf#", "/docs/a.pdf#page=0", "/docs/a.pdf#page=two", "/docs/a.pdf#page=-1", "/docs/a.pdf#nameddest=", "/docs/a.pdf#view=Fit"]
{
assert_eq!(split(odd, &pdf), Some(("/docs/a.pdf".into(), RemoteDestination::Default)), "{odd}");
}
assert_eq!(split("/docs/a.pdf#page=99999999999999999999999", &pdf), Some(("/docs/a.pdf".into(), RemoteDestination::Default)));
}
#[test]
fn a_path_that_names_a_file_is_left_alone() {
// A file whose name has a `#` in it, alone and with parameters of its own.
let hashed = files(&["/docs/C#.pdf", "/docs/#1/a.pdf"]);
assert_eq!(split("/docs/C#.pdf", &hashed), None);
assert_eq!(split("/docs/C#.pdf#page=2", &hashed), Some(("/docs/C#.pdf".into(), page(1))));
assert_eq!(split("/docs/#1/a.pdf#page=2", &hashed), Some(("/docs/#1/a.pdf".into(), page(1))));
// Nothing before a `#` is a file: the open fails on the path as given.
assert_eq!(split("/docs/missing.pdf#page=2", &hashed), None);
assert_eq!(split("/docs/plain.pdf", &hashed), None);
assert_eq!(split("#", &hashed), None);
}
}
+3
View File
@@ -45,6 +45,9 @@ pub struct Session {
pub(crate) date_format: Option<String>,
/// Preferences ▸ Date format ▸ Language, when not following the interface language.
pub(crate) date_language: Option<String>,
/// Highlight fields, as the viewer shows it: what forms' scripts read as
/// `app.runtimeHighlight` (off by default, as in a session without a viewer).
pub(crate) field_highlight: bool,
/// What the XFA scripts' time limits are multiplied by (`None`: 1); see
/// [`Session::with_xfa_time_scale`].
pub(crate) xfa_time_scale: Option<u32>,
+57 -11
View File
@@ -36,6 +36,7 @@ impl Session {
let now = self.now();
let today = self.today();
let js_off = self.js_off;
let highlight = self.field_highlight;
let xfa_time_scale = self.xfa_time_scale.unwrap_or(1);
let doc = self.doc_mut(id)?;
doc.edit_notices.clear();
@@ -48,6 +49,7 @@ impl Session {
}
cx.xfa_time_scale = xfa_time_scale;
cx.pages = doc.info.pages.len();
cx.highlight = highlight;
cx.today = today;
let reason = doc.read_only_reason.clone().unwrap_or_default();
let signed = doc.is_signed();
@@ -85,13 +87,20 @@ impl Session {
}
let mut next = editor.cos.clone();
if !js_off && uses_scripts(&edit) {
cx.js = Some(js::JsRunner::new(&next, &name));
cx.js = Some(js::JsRunner::new(&next, &name, highlight));
}
cx.renamed_fields.clear();
cx.redaction_overlays.clear();
// `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()))))?;
// A field's enter or exit scripts that changed nothing leave no edit behind (no undo step,
// nothing unsaved); what they showed or asked for still goes out. Only an event on its own:
// a batch keeps the other edits it holds.
if cx.xfa_unchanged && matches!(edit, Edit::XfaEvent { .. }) {
take_xfa_output(doc, cx.xfa_out, cx.xfa_ran_away);
return Ok(None);
}
// 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 {
@@ -130,7 +139,8 @@ impl Session {
};
// An XFA form's scripts answer the change: exit and validate scripts of the field, then
// every calculation (and a reset recalculates). They read the data, so it is brought up
// to date first.
// to date first. The fields whose exit scripts ran are noted once the edit is kept.
let mut exited: Vec<String> = Vec::new();
if let Some(tpl) = cx.xfa.clone() {
if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) {
let datasets = &mut cx.xfa_datasets;
@@ -139,6 +149,8 @@ impl Session {
.map_err(EditError::Write)?;
cx.xfa_out.errors.extend(notes);
}
/// The fields edit `e` (and the edits it batches) gives new values, and whether it
/// resets the form.
fn collect(e: &Edit, changed: &mut Vec<String>, reset: &mut bool) {
match e {
Edit::SetFieldValue { name, .. } => changed.push(name.clone()),
@@ -151,19 +163,24 @@ impl Session {
let mut reset = false;
collect(&edit, &mut changed, &mut reset);
if !changed.is_empty() || reset {
let highlight = cx.highlight();
let mut run = xfa::XfaRun {
page_count: cx.pages,
out: &mut cx.xfa_out,
datasets: cx.xfa_datasets,
ran_away: false,
highlight,
time_scale: cx.xfa_time_scale,
};
// Every changed field's scripts, then the calculations once (after a reset,
// only those).
let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run))
let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run, &mut exited))
.unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})")));
cx.xfa_datasets = run.datasets;
cx.xfa_ran_away |= run.ran_away;
// The next edit of a batch reads the highlight this one's scripts set.
let on = run.highlight;
cx.set_highlight(on);
if let Err(e) = ran {
cx.xfa_out.errors.push(e);
}
@@ -201,7 +218,13 @@ impl Session {
if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) {
return Err(EditError::SignedRewrite(edit.label().to_string()));
}
let notices = edit_notices(&edit, &editor.cos, &next);
let mut notices = edit_notices(&edit, &editor.cos, &next);
// Places applied redactions left that still hold the removed text, and what became of
// the PDF/A or PDF/UA identification in the XMP they removed: said, not silent.
notices.extend(cx.redaction_elsewhere.filter(|e| e.total() > 0 || e.unsearched).map(EditNotice::RedactionElsewhere));
if cx.redaction_identification != pdfcraft_redact::Identification::default() {
notices.push(EditNotice::RedactionIdentification(cx.redaction_identification));
}
let previous = std::mem::replace(&mut editor.cos, next);
let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone());
let previous_keys = std::mem::replace(&mut editor.keys, keys);
@@ -230,6 +253,10 @@ impl Session {
doc.dirty = true;
doc.generation += 1;
doc.edit_notices = notices;
// Only this edit's exits are skipped on the next focus loss: one an earlier change ran is
// no reason to skip a later one.
doc.xfa_exited.clear();
doc.xfa_exited.extend(exited);
if !cx.xfa_removed.is_empty() {
let removed: Vec<String> = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect();
doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX));
@@ -242,15 +269,11 @@ impl Session {
if let Some(js) = cx.js {
doc.js_output.append(js.output);
}
doc.js_output.append(cx.xfa_out);
if cx.xfa_ran_away {
doc.xfa_template = None;
doc.xfa_scripts_off = true;
doc.js_output.errors.push(XFA_SCRIPTS_OFF.into());
}
take_xfa_output(doc, cx.xfa_out, cx.xfa_ran_away);
Ok(cx.prepared)
}
/// Undo the last edit of document `id`, keeping it for [`Session::redo`]. Returns its label.
pub fn undo(&mut self, id: DocId) -> Result<Label, EditError> {
let doc = self.doc_mut(id)?;
let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?;
@@ -276,10 +299,13 @@ impl Session {
}
doc.edit_notices.clear();
Self::sync_dirty(doc);
// The change whose exit ran is undone (or redone without its scripts): skip no exit.
doc.xfa_exited.clear();
doc.generation += 1;
Ok(label)
}
/// Redo the last edit of document `id` that [`Session::undo`] took back. Returns its label.
pub fn redo(&mut self, id: DocId) -> Result<Label, EditError> {
let doc = self.doc_mut(id)?;
let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?;
@@ -305,6 +331,8 @@ impl Session {
}
doc.edit_notices.clear();
Self::sync_dirty(doc);
// The change whose exit ran is undone (or redone without its scripts): skip no exit.
doc.xfa_exited.clear();
doc.generation += 1;
Ok(label)
}
@@ -444,7 +472,14 @@ impl Session {
) || matches!(e, Edit::MoveObjects { objects, .. } | Edit::DeleteObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))
});
let touches_marks = edit.is_none_or(|e| {
matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. })
matches!(
e,
Edit::AddHeaderFooter { .. }
| Edit::AddWatermark { .. }
| Edit::AddBackground { .. }
| Edit::AddPrinterMarks { .. }
| Edit::RemoveMarks { .. }
)
});
(touches_added, touches_marks)
}
@@ -574,6 +609,17 @@ impl Session {
}
}
/// Hand on what an edit's XFA scripts showed or asked for (`out`), and when one of them ran away,
/// turn the form's scripts off and say so.
fn take_xfa_output(doc: &mut Document, out: js::JsOutput, ran_away: bool) {
doc.js_output.append(out);
if ran_away {
doc.xfa_template = None;
doc.xfa_scripts_off = true;
doc.js_output.errors.push(XFA_SCRIPTS_OFF.into());
}
}
/// What `edit`, taking `prev` to `next`, wants said: never silent (AGENTS.md §4).
fn edit_notices(edit: &Edit, prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec<EditNotice> {
// What it drew with `?` (a value or comment text its font can't draw, stored as typed).
+18
View File
@@ -116,6 +116,24 @@ impl Session {
.map_err(EditError::Write)
}
/// What Convert colours would change on `pages` of `id`, without changing the document.
pub fn count_colours(&self, id: DocId, pages: &[usize], settings: &pdfcraft_edit::ConvertColours) -> pdfcraft_edit::ColoursConverted {
let Some(doc) = self.get(id) else { return Default::default() };
let Some(editor) = doc.editor.as_ref() else { return Default::default() };
let mut cos = editor.cos.clone();
let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, t: pdfcraft_edit::ColourTarget| {
let target = match t {
pdfcraft_edit::ColourTarget::Gray => pdfcraft_optimize::ImageTarget::Gray,
pdfcraft_edit::ColourTarget::Rgb => pdfcraft_optimize::ImageTarget::Rgb,
pdfcraft_edit::ColourTarget::Cmyk => pdfcraft_optimize::ImageTarget::Cmyk,
};
pdfcraft_optimize::recolour_image(doc, s, target)
};
// The preview runs the image decoders over the document's own bytes; a panic in one is
// a count of nothing, not a crash.
guard(|| pdfcraft_edit::convert_colours(&mut cos, pages, settings, &mut recolour).unwrap_or_default()).unwrap_or_default()
}
/// How many hairlines [`Edit::FixHairlines`] would change on `pages` of `id`, and on how many
/// pages, without changing the document (the edit itself refuses when there is nothing to fix).
pub fn count_hairlines(&self, id: DocId, pages: &[usize], settings: &Hairlines) -> HairlinesFixed {
+2
View File
@@ -145,6 +145,8 @@ impl Session {
editor.redo.clear();
Self::adopt_keys(doc);
Self::sync_dirty(doc);
// The changes whose exits ran are gone with the rest: skip no exit.
doc.xfa_exited.clear();
doc.generation += 1;
Self::refresh(doc).inspect_err(|_| Self::show_released(doc))
}
+38 -4
View File
@@ -62,20 +62,54 @@ impl Session {
return None;
}
/// Today's date in local time: (year, month, day). With an injected clock (tests) the clock
/// is taken as local time.
/// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time).
pub fn stamp_by_line(&self, author: &str) -> String {
let (date, time) = self.stamp_date_time();
let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) };
format!("{who}at {time}, {date}")
}
/// A custom stamp's text line (#1741): `template` with `{name}` (the author), `{date}`
/// ("Oct 02, 2026") and `{time}` ("2:14 pm") filled in now, in local time, as the dynamic
/// stamps' "By" line has them. Cut to [`MAX_STAMP_LINE`](crate::MAX_STAMP_LINE) characters.
pub fn stamp_line(&self, template: &str, author: &str) -> String {
let (date, time) = self.stamp_date_time();
// One pass over the template, so a placeholder the author's name happens to contain
// (`Ada {date}`) stays as written rather than being filled in too.
let mut filled = String::new();
let mut rest = template.trim();
while let Some(c) = rest.chars().next() {
if let Some(after) = rest.strip_prefix("{name}") {
filled.push_str(author.trim());
rest = after;
} else if let Some(after) = rest.strip_prefix("{date}") {
filled.push_str(&date);
rest = after;
} else if let Some(after) = rest.strip_prefix("{time}") {
filled.push_str(&time);
rest = after;
} else {
filled.push(c);
rest = rest.get(c.len_utf8()..).unwrap_or_default();
}
}
filled.chars().take(crate::MAX_STAMP_LINE).collect()
}
/// Now, as the stamps write it: ("Oct 02, 2026", "2:14 pm"), local time.
fn stamp_date_time(&self) -> (String, String) {
let offset = self.local_offset();
let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default();
let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14));
const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
let h12 = if hh % 12 == 0 { 12 } else { hh % 12 };
let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) };
format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize])
let month = MONTHS.get(mo.clamp(1, 12) as usize - 1).copied().unwrap_or("Jan");
(format!("{month} {day:02}, {y}"), format!("{h12}:{mm:02} {}", if hh < 12 { "am" } else { "pm" }))
}
/// Today's date in local time: (year, month, day). With an injected clock (tests) the clock
/// is taken as local time.
pub fn today(&self) -> (i64, u32, u32) {
let offset = self.local_offset();
self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1))
+1
View File
@@ -241,6 +241,7 @@ impl SignatureImage {
name: if initials { "Initials" } else { "Signature" }.into(),
file: MarkFile { name: "signature.png".into(), bytes: self.bytes.clone(), page: 0 },
author: author.into(),
line: None,
}],
})
}
+1460 -21
View File
File diff suppressed because it is too large. Load diff
+627 -43
View File
@@ -5,12 +5,12 @@
//! presence and access changes become overrides kept in the PDF, added or removed rows change
//! the datasets, and any of those lays the form out again.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode};
use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaProp, XfaProps};
use pdfcraft_js::xfa_session::{Script, XfaSession};
use pdfcraft_xfa::model::Template;
use pdfcraft_xfa::model::{Prop, Props, Template};
use pdfcraft_xfa::{FormNode, LiveForm, NodeKind, ScriptEvent};
use crate::js::JsOutput;
@@ -53,6 +53,7 @@ fn click_only(e: &XfaEffect) -> Option<&'static str> {
XfaEffect::Print => Some("print"),
XfaEffect::SaveAs => Some("save"),
XfaEffect::LaunchUrl(_) => Some("open a link"),
XfaEffect::OpenAttachment { .. } => Some("open an attached file"),
XfaEffect::ShowPanel(_) => Some("show a side panel"),
XfaEffect::SetFocus(_) => Some("move the focus"),
_ => None,
@@ -128,14 +129,102 @@ pub(crate) struct XfaRun<'a> {
pub datasets: Option<pdfcraft_cos::ObjRef>,
/// A script ran past its time limit and was abandoned: the form's scripts should stay off.
pub ran_away: bool,
/// Whether the viewer highlights form fields, for `app.runtimeHighlight`.
pub highlight: bool,
/// What every time limit of the scripts is multiplied by (the event's budget, the engine's
/// start and each script's own): 1, unless the session was given another
/// ([`crate::Session::with_xfa_time_scale`]).
pub time_scale: u32,
}
/// The live form node `n`, with its descendants, as the script engine's snapshot of it.
fn to_js(n: &FormNode) -> XfaNode {
/// The script engine's copies of a form's property trees, by the address of the tree each was
/// made from: made the first time a script of the form needs one, then shared by every later
/// script and event of that form. They are kept in the form's template
/// ([`pdfcraft_xfa::model::TemplateCache`]), so they go with it when the form is closed, and
/// the lock is the form's own. Each entry holds a weak reference to its tree, which keeps the
/// address from being reused while the entry exists; entries whose tree is gone (a tree of a
/// copy of the template, such as one with a script's overrides applied) are dropped each time
/// the map is used.
type PropsCopies = HashMap<usize, (std::sync::Weak<Props>, Arc<XfaProps>)>;
type PropsCache = std::sync::Mutex<PropsCopies>;
/// The form's map of copies, with the entries of trees that are gone dropped; `None` only when
/// the template's cache holds something else. A poisoned lock only means a panic elsewhere
/// while it was held: the map is still a valid memo.
fn props_copies(tpl: &Template) -> Option<std::sync::MutexGuard<'_, PropsCopies>> {
let mut copies = tpl.cache.get_or_init(PropsCache::default)?.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
copies.retain(|_, (tree, _)| tree.strong_count() > 0);
Some(copies)
}
/// The copy made of `p`, if there is one.
fn copy_of(copies: &PropsCopies, p: &Arc<Props>) -> Option<Arc<XfaProps>> {
let (tree, copy) = copies.get(&(Arc::as_ptr(p) as usize))?;
(std::sync::Weak::as_ptr(tree) == Arc::as_ptr(p) && tree.strong_count() > 0).then(|| Arc::clone(copy))
}
/// The script engine's copy of the template's property tree `p`.
fn convert_props(p: &Props) -> XfaProps {
fn prop(p: &Prop, depth: usize) -> XfaProp {
XfaProp {
tag: p.tag.clone(),
attrs: p.attrs.clone(),
// Shared, not copied: a validation's script can be long.
text: p.text.clone(),
// The parser keeps 8 levels; this bounds the walk whatever builds the tree.
children: if depth > 64 { Vec::new() } else { p.children.iter().map(|c| prop(c, depth + 1)).collect() },
}
}
XfaProps { attrs: p.attrs.clone(), elements: p.elements.iter().map(|e| prop(e, 0)).collect() }
}
/// The live form node `n` of the form whose template is `tpl`, with its descendants, as the
/// script engine's snapshot of it; the property trees are the form's shared copies
/// ([`PropsCopies`]), not copied again. The map is locked only to look copies up and to add the
/// ones made (outside the lock) for trees met for the first time.
fn to_js(n: &FormNode, tpl: &Template) -> XfaNode {
fn trees<'a>(n: &'a FormNode, out: &mut HashMap<usize, &'a Arc<Props>>, depth: usize) {
out.entry(Arc::as_ptr(&n.props) as usize).or_insert(&n.props);
if depth <= 256 {
n.children.iter().for_each(|c| trees(c, out, depth + 1));
}
}
let mut needed = HashMap::new();
trees(n, &mut needed, 0);
let mut found: HashMap<usize, Arc<XfaProps>> = HashMap::with_capacity(needed.len());
let missing: Vec<&Arc<Props>> = {
let copies = props_copies(tpl);
needed
.iter()
.filter_map(|(&key, p)| match copies.as_ref().and_then(|c| copy_of(c, p)) {
Some(copy) => {
found.insert(key, copy);
None
}
None => Some(*p),
})
.collect()
};
if !missing.is_empty() {
let made: Vec<(&Arc<Props>, Arc<XfaProps>)> = missing.into_iter().map(|p| (p, Arc::new(convert_props(p)))).collect();
let mut copies = props_copies(tpl);
for (p, copy) in made {
let key = Arc::as_ptr(p) as usize;
// Another thread may have made one meanwhile: keep the first.
let copy = match copies.as_mut() {
Some(c) => copy_of(c, p).unwrap_or_else(|| {
c.insert(key, (Arc::downgrade(p), Arc::clone(&copy)));
copy
}),
None => copy,
};
found.insert(key, copy);
}
}
to_js_with(n, &found, 0)
}
fn to_js_with(n: &FormNode, copies: &HashMap<usize, Arc<XfaProps>>, depth: usize) -> XfaNode {
XfaNode {
name: n.name.clone(),
som: n.som.clone(),
@@ -154,11 +243,62 @@ fn to_js(n: &FormNode) -> XfaNode {
occur_min: n.occur_min,
occur_max: n.occur_max,
index: n.index,
children: n.children.iter().map(to_js).collect(),
children: if depth > 256 { Vec::new() } else { n.children.iter().map(|c| to_js_with(c, copies, depth + 1)).collect() },
props: copies.get(&(Arc::as_ptr(&n.props) as usize)).cloned().unwrap_or_else(|| Arc::new(convert_props(&n.props))),
formatted: n.display_picture.as_deref().and_then(|p| pdfcraft_xfa::formatted_value(p, &n.value)),
}
}
/// The names of a run of `resetData` calls, applied together: each name once, in the order
/// first given. A call without names resets the whole form, which takes in every other.
#[derive(Default)]
struct ResetRun {
names: Vec<String>,
/// `names` as a set, so adding a name costs the same however many there are (a script
/// may pass hundreds of thousands).
seen: HashSet<String>,
whole: bool,
}
impl ResetRun {
fn add(&mut self, names: &[String]) {
if self.whole {
return;
}
if names.is_empty() {
self.whole = true;
self.names.clear();
self.seen.clear();
return;
}
for n in names {
if self.seen.insert(n.clone()) {
self.names.push(n.clone());
}
}
}
/// The names to reset; none for the whole form.
fn names(&self) -> &[String] {
if self.whole { &[] } else { &self.names }
}
}
/// The SOM paths of the live form's fields under `n`, at most 64 levels deep, into `out`.
fn field_soms(n: &FormNode, out: &mut HashSet<String>, depth: usize) {
if depth > 64 {
return;
}
if n.kind == Some(NodeKind::Field) {
out.insert(n.som.clone());
}
for c in &n.children {
field_soms(c, out, depth + 1);
}
}
/// The subform or other container at SOM path `som` in the live form under `n` (fields are
/// skipped), searched at most 64 levels deep.
fn find_container<'a>(n: &'a FormNode, som: &str, depth: usize) -> Option<&'a FormNode> {
if depth > 64 {
return None;
@@ -169,6 +309,10 @@ fn find_container<'a>(n: &'a FormNode, som: &str, depth: usize) -> Option<&'a Fo
n.children.iter().find_map(|c| find_container(c, som, depth + 1))
}
fn has_node(n: &FormNode, som: &str, depth: usize) -> bool {
depth <= 64 && (n.som == som || n.children.iter().any(|c| has_node(c, som, depth + 1)))
}
fn count_instances(root: &FormNode, parent_som: &str, name: &str) -> usize {
let parent = if parent_som.is_empty() { Some(root) } else { find_container(root, parent_som, 0) };
parent.map_or(0, |p| p.children.iter().filter(|c| c.name == name).count())
@@ -198,15 +342,52 @@ fn plain_som(som: &str) -> String {
out
}
/// Does `name` from a script (`qty`, `page1.qty`, `xfa.form.form.page1.qty`, or a container
/// such as `table`) name this SOM or one of its ancestors?
fn som_matches(som: &str, name: &str) -> bool {
let plain = plain_som(som);
let n = plain_som(name.trim());
let n = n.strip_prefix("xfa.form.").or_else(|| n.strip_prefix("$form.")).unwrap_or(&n);
let want: Vec<&str> = n.split('.').filter(|s| !s.is_empty()).collect();
let have: Vec<&str> = plain.split('.').filter(|s| !s.is_empty()).collect();
!want.is_empty() && have.windows(want.len()).any(|w| w == want.as_slice())
/// The names a script passed to a reset (`qty`, `page1.qty`, `xfa.form.form.page1.qty`, or a
/// container such as `table`), indexed, so matching an object against them costs the same
/// however many names there are.
struct ResetTargets {
/// Each name's steps without indexes, joined by `.`.
wants: HashSet<String>,
/// The most steps of a name.
longest: usize,
}
impl ResetTargets {
fn new(names: &[String]) -> Self {
let mut wants = HashSet::new();
let mut longest = 0;
for name in names {
let n = plain_som(name.trim());
let n = n.strip_prefix("xfa.form.").or_else(|| n.strip_prefix("$form.")).unwrap_or(&n);
let steps: Vec<&str> = n.split('.').filter(|s| !s.is_empty()).collect();
if !steps.is_empty() {
longest = longest.max(steps.len());
wants.insert(steps.join("."));
}
}
ResetTargets { wants, longest }
}
/// Does a name name this SOM or one of its ancestors: is it a run of the SOM's steps? The
/// runs are looked up, at most [`Self::longest`] steps each, so the work depends on the
/// SOM's depth and not on how many names there are.
fn matches(&self, som: &str) -> bool {
let plain = plain_som(som);
let have: Vec<&str> = plain.split('.').filter(|s| !s.is_empty()).collect();
for start in 0..have.len() {
let mut run = String::new();
for (k, step) in have.iter().skip(start).take(self.longest).enumerate() {
if k > 0 {
run.push('.');
}
run.push_str(step);
if self.wants.contains(&run) {
return true;
}
}
}
false
}
}
/// A generated field as the runner tracks it: kind, value and on state.
@@ -256,6 +437,11 @@ struct Runner<'a, 'b> {
skipped: usize,
relayouts: usize,
relayouts_skipped: bool,
/// Lay the form out once, when the event ends, rather than after every script that changed
/// its structure (opening: see [`on_open`]).
defer_relayout: bool,
/// A deferred layout is owed.
relayout_owed: bool,
dropped_output: usize,
truncated_warned: bool,
data: Option<pdfcraft_xfa::DataNode>,
@@ -270,17 +456,25 @@ struct Runner<'a, 'b> {
fields: Option<HashMap<String, FieldInfo>>,
/// The live form as last built; dropped when a script changed the form.
cached: Option<LiveForm>,
/// `cached` as the scripts get it, made once and sent to each script until the live form
/// changes in any way (a value too): the script engine reuses what it built from it.
js_root: Option<Arc<XfaNode>>,
/// The script engine for this event: one thread for all its scripts, each run in a fresh
/// context on the form as it is then (`cached`, kept up to date in place for values and
/// rebuilt when the structure changed).
session: Option<XfaSession>,
changes: Changes,
/// Fields the layout numbers (a field whose own layout:ready script puts a page number in
/// it): while layout:ready scripts run, a value written to one is dropped, so each page keeps
/// the number the layout gave it.
numbered: HashSet<String>,
/// A field embedded in rich text (a floating field) took a new value since the form was
/// last laid out: the text that shows it is laid out again when the event ends.
embedded_changed: bool,
}
impl<'a, 'b> Runner<'a, 'b> {
/// A runner for one event on `doc` and its template, its scripts sharing `budget_ms`.
fn new(doc: &'a mut pdfcraft_cos::Document, tpl: &'a Template, run: &'a mut XfaRun<'b>, budget_ms: u64) -> Self {
let data = pdfcraft_xfa::data_of(doc);
let ov = pdfcraft_xfa::overrides(doc);
@@ -294,6 +488,8 @@ impl<'a, 'b> Runner<'a, 'b> {
skipped: 0,
relayouts: 0,
relayouts_skipped: false,
defer_relayout: false,
relayout_owed: false,
dropped_output: 0,
truncated_warned: false,
data,
@@ -305,8 +501,10 @@ impl<'a, 'b> Runner<'a, 'b> {
by_path: None,
fields: None,
cached: None,
js_root: None,
session: None,
changes: Changes::default(),
numbered: HashSet::new(),
embedded_changed: false,
}
}
@@ -315,11 +513,12 @@ impl<'a, 'b> Runner<'a, 'b> {
/// script.
fn invalidate(&mut self) {
self.cached = None;
self.js_root = None;
}
/// The script engine for this event.
fn session(&mut self) -> &mut XfaSession {
let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count };
let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count, highlight: self.run.highlight };
let scale = self.run.time_scale;
self.session.get_or_insert_with(|| XfaSession::new(doc_info).with_time_scale(scale))
}
@@ -331,6 +530,8 @@ impl<'a, 'b> Runner<'a, 'b> {
let op = pdfcraft_xfa::DataOp::Value { path, text };
self.data.get_or_insert_with(Default::default).apply(&op);
self.pending.push(op);
// The value changes in the live form below: the scripts' copy is made again.
self.js_root = None;
let refreshed = match self.cached.as_mut() {
Some(live) => pdfcraft_xfa::refresh_value(&mut live.root, self.tpl, self.data.as_ref(), som).is_some(),
None => true,
@@ -342,7 +543,8 @@ impl<'a, 'b> Runner<'a, 'b> {
fn live(&mut self) -> &LiveForm {
if self.cached.is_none() {
let live = pdfcraft_xfa::form_tree(self.tpl, self.data.as_ref(), &self.ov);
let uses = pdfcraft_xfa::page_area_uses(self.doc, self.tpl);
let live = pdfcraft_xfa::form_tree_paged(self.tpl, self.data.as_ref(), &self.ov, &uses);
if live.truncated && !self.truncated_warned {
self.truncated_warned = true;
self.error(format!(
@@ -545,11 +747,37 @@ impl<'a, 'b> Runner<'a, 'b> {
fn reset(&mut self, names: &[String]) -> Result<(), String> {
self.flush()?;
let by_som = self.by_som().clone();
let listed: Option<Vec<String>> = if names.is_empty() {
None
} else {
Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect())
let targets = (!names.is_empty()).then(|| ResetTargets::new(names));
let wanted = |som: &str| targets.as_ref().is_none_or(|t| t.matches(som));
let listed: Option<Vec<String>> = targets.as_ref().map(|_| by_som.iter().filter(|(som, _)| wanted(som)).map(|(_, f)| f.clone()).collect());
// Fields and exclusion groups of the live form with no widget yet (a row an opening
// script added, before the form is laid out again): their data goes back to the
// template's default too, or the value would stay and be laid out and saved.
let unshown: Vec<String> = {
let live = self.live();
let mut out = Vec::new();
let mut stack: Vec<&pdfcraft_xfa::FormNode> = vec![&live.root];
while let Some(n) = stack.pop() {
if matches!(n.kind, Some(pdfcraft_xfa::NodeKind::Field | pdfcraft_xfa::NodeKind::ExclGroup))
&& !by_som.contains_key(&n.som)
&& wanted(&n.som)
{
out.push(n.som.clone());
}
stack.extend(n.children.iter());
}
out
};
for som in unshown {
// A floating field rich text embeds goes back to its default with every row that
// holds it (below).
if pdfcraft_xfa::field_is_embedded(self.tpl, &som) {
continue;
}
let Some(text) = pdfcraft_xfa::reset_default(self.tpl, &som) else { continue };
let path = pdfcraft_xfa::data_path(self.tpl, &som);
self.reset_data(path, text);
}
// Names no widget answers to may still name a floating field (below).
let widgets = !listed.as_ref().is_some_and(Vec::is_empty);
if widgets {
@@ -557,6 +785,7 @@ impl<'a, 'b> Runner<'a, 'b> {
}
self.fields = None;
let infos = self.fields().clone();
let listed: Option<HashSet<&String>> = listed.as_ref().map(|l| l.iter().collect());
for (som, name) in by_som.iter().filter(|_| widgets) {
if listed.as_ref().is_some_and(|l| !l.contains(name)) {
continue;
@@ -566,17 +795,12 @@ impl<'a, 'b> Runner<'a, 'b> {
continue;
}
let path = pdfcraft_xfa::data_path(self.tpl, som);
let text = data_text(info);
if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text {
self.embedded_changed |= pdfcraft_xfa::path_is_embedded(self.tpl, &path);
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
self.changes.values += 1;
}
self.reset_data(path, data_text(info));
}
// A floating field without a widget (a hidden one, as forms place them) goes back to
// its default too, and the text that embeds it shows that.
for (som, default) in pdfcraft_xfa::embedded_fields(self.tpl) {
if by_som.contains_key(&som) || (!names.is_empty() && !names.iter().any(|n| som_matches(&som, n))) {
if by_som.contains_key(&som) || !wanted(&som) {
continue;
}
// Every row that holds it, not only the first.
@@ -594,6 +818,16 @@ impl<'a, 'b> Runner<'a, 'b> {
Ok(())
}
/// Put a reset's `text` in the data at `path`, unless it already holds it: a multi-select
/// list's picks (`value` elements) are compared as its lines (the write replaces them).
fn reset_data(&mut self, path: pdfcraft_xfa::DataPath, text: String) {
if self.data.as_ref().and_then(|d| d.value_at(&path)).unwrap_or_default() != text {
self.embedded_changed |= pdfcraft_xfa::path_is_embedded(self.tpl, &path);
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
self.changes.values += 1;
}
}
/// Apply what a script of `ev` did. Returns whether the form has to be laid out again.
fn apply_effects(&mut self, ev: &ScriptEvent, effects: &[XfaEffect]) -> Result<bool, String> {
let mut relayout = false;
@@ -612,7 +846,9 @@ impl<'a, 'b> Runner<'a, 'b> {
}
}
}
for e in effects {
// Resets in a row, applied together when the run of them ends.
let mut pending_reset: Option<ResetRun> = None;
for (at, e) in effects.iter().enumerate() {
// Only a click script may print, save, open links or move the focus (not the
// calculations a click sets off, nor anything run on open or on a change).
if let Some(what) = click_only(e)
@@ -624,7 +860,9 @@ impl<'a, 'b> Runner<'a, 'b> {
}
match e {
XfaEffect::SetValue { som, value } => {
self.set_value(som, value);
if !self.numbered.contains(som) {
self.set_value(som, value);
}
}
XfaEffect::SetPresence { som, presence } => {
if self.ov.presence.insert(som.clone(), presence.clone()).as_ref() != Some(presence) {
@@ -661,10 +899,19 @@ impl<'a, 'b> Runner<'a, 'b> {
relayout = true;
}
XfaEffect::MessageBox(m) => self.alert(m.clone()),
XfaEffect::ResetData(names) => self.reset(names)?,
XfaEffect::ResetData(names) => {
// A script resetting several subforms in a row (`resetData` once for each)
// resets them together: the same fields end up at their defaults.
pending_reset.get_or_insert_with(ResetRun::default).add(names);
let next_is_reset = effects.get(at.saturating_add(1)).is_some_and(|e| matches!(e, XfaEffect::ResetData(_)));
if !next_is_reset && let Some(run) = pending_reset.take() {
self.reset(run.names())?;
}
}
XfaEffect::Print => self.run.out.requests.push(Request::Print),
XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs),
XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())),
XfaEffect::OpenAttachment { name, open } => self.run.out.requests.push(Request::OpenAttachment { name: name.clone(), open: *open }),
XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)),
XfaEffect::SetFocus(som) => {
if let Some(name) = self.by_som().get(som).cloned() {
@@ -672,6 +919,13 @@ impl<'a, 'b> Runner<'a, 'b> {
}
}
XfaEffect::Beep => self.run.out.requests.push(Request::Beep),
XfaEffect::Highlight(on) => {
self.run.highlight = *on;
// The session's scripts read the setting it started with: the next script
// gets a session that reads the new one.
self.session = None;
self.run.out.requests.push(Request::Highlight(*on));
}
XfaEffect::Recalculate => {}
XfaEffect::Relayout => relayout = true,
}
@@ -686,11 +940,25 @@ impl<'a, 'b> Runner<'a, 'b> {
self.skipped += 1;
return Ok(None);
}
// An event listed before a script relaid the form out can name an object that is gone
// (a page the form no longer fills, a row removed): it isn't run, rather than run with
// `this` at some other object.
if !has_node(&self.live().root, &ev.som, 0) {
return Ok(None);
}
self.runs += 1;
#[cfg(test)]
self.check_live();
let event = XfaEvent { activity: ev.activity.clone(), target: ev.som.clone(), new_text: new_text.to_string(), prev_text: String::new() };
let root = to_js(&self.live().root);
let tpl = self.tpl;
let root = match &self.js_root {
Some(r) => Arc::clone(r),
None => {
let r = Arc::new(to_js(&self.live().root, tpl));
self.js_root = Some(Arc::clone(&r));
r
}
};
let script = Script {
source: ev.script.clone(),
formcalc: ev.formcalc,
@@ -717,7 +985,11 @@ impl<'a, 'b> Runner<'a, 'b> {
self.error(format!("{} ({}): {e}", ev.som, ev.activity));
}
if self.apply_effects(ev, &o.effects)? {
self.relayout()?;
if self.defer_relayout {
self.relayout_owed = true;
} else {
self.relayout()?;
}
}
Ok(Some(o))
}
@@ -747,9 +1019,10 @@ impl<'a, 'b> Runner<'a, 'b> {
fn finish(mut self) -> Result<Changes, String> {
#[cfg(test)]
self.check_live();
if self.relayouts_skipped || self.embedded_changed {
if self.relayouts_skipped || self.relayout_owed || self.embedded_changed {
self.relayouts = 0;
self.relayouts_skipped = false;
self.relayout_owed = false;
self.relayout()?;
}
self.flush()?;
@@ -780,7 +1053,8 @@ impl Runner<'_, '_> {
#[cfg(test)]
fn check_live(&mut self) {
let Some(live) = self.cached.as_ref() else { return };
let fresh = pdfcraft_xfa::form_tree(self.tpl, self.data.as_ref(), &self.ov);
let uses = pdfcraft_xfa::page_area_uses(self.doc, self.tpl);
let fresh = pdfcraft_xfa::form_tree_paged(self.tpl, self.data.as_ref(), &self.ov, &uses);
let (mut have, mut want) = (Vec::new(), Vec::new());
collect_fields(&live.root, &mut have, 0);
collect_fields(&fresh.root, &mut want, 0);
@@ -908,13 +1182,40 @@ fn value_unchanged(doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field, v: O
}
}
/// On open: initialize scripts, then calculations. Returns what they changed.
/// On open: initialize scripts, then layout:ready scripts (the form is laid out by now), then
/// calculations. Returns what they changed.
///
/// layout:ready runs once here, where forms set themselves up (turning field highlighting on,
/// say); a viewer runs it after every layout. Every layout:ready script runs, but a field that
/// numbers pages keeps the number the layout gave each page: the value a script writes to it is
/// dropped (one value would put the same number on every page).
pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result<Changes, String> {
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
// The scripts read the live form (template, data and overrides), not the pages, so the
// pages are laid out once, after the last of them. Laying a large form out after every
// script that hides a subform spent the time limit on the first few, and the form was kept
// half initialized: a page its initialize scripts hide stayed open to typing until the
// next opening hid it and reset its data.
r.defer_relayout = true;
let events: Vec<ScriptEvent> = r.live().events.iter().filter(|e| e.activity == "initialize" || e.activity == "docReady").cloned().collect();
for ev in &events {
r.run(ev, "")?;
}
let (ready, numbered) = {
let live = r.live();
let ready: Vec<ScriptEvent> = live.events.iter().filter(|e| e.activity == "layoutReady").cloned().collect();
// The fields, gathered in one walk rather than one per script.
let mut fields = HashSet::new();
field_soms(&live.root, &mut fields, 0);
let numbered: HashSet<String> =
ready.iter().filter(|e| fields.contains(&e.som) && pdfcraft_xfa::numbers_pages(&e.script, e.formcalc)).map(|e| e.som.clone()).collect();
(ready, numbered)
};
r.numbered = numbered;
for ev in &ready {
r.run(ev, "")?;
}
r.numbered.clear();
r.calculates()?;
r.finish()
}
@@ -922,16 +1223,34 @@ pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mu
/// After fields `names` took new values: each one's change, exit and validate scripts, then
/// the calculations once (or only those, after a reset). A validate script that answers false,
/// or fails, shows its message; the value stays, as Acrobat marks the field invalid rather
/// than reverting it.
pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names: &[String], run: &mut XfaRun) -> Result<(), String> {
/// than reverting it. The SOM paths whose exit scripts ran go into `exited`.
pub(crate) fn on_changes(
doc: &mut pdfcraft_cos::Document,
tpl: &Template,
names: &[String],
run: &mut XfaRun,
exited: &mut Vec<String>,
) -> Result<(), String> {
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
for name in names {
let som = r.by_som().iter().find(|(_, f)| *f == name).map(|(s, _)| s.clone());
let Some(som) = som else { continue };
let value = r.fields().get(name).map(|f| f.value.join("\n")).unwrap_or_default();
for ev in r.events_of(&som, "change").into_iter().chain(r.events_of(&som, "exit")) {
for ev in r.events_of(&som, "change") {
r.run(&ev, &value)?;
}
// Noted only when an exit script ran, whether it succeeded or failed: it has run for this
// focus loss, and a failed script keeps what it changed before its error, so running it
// again would repeat that and the error. One the event's budget skipped, or one never
// reached because an earlier script stopped the run, is still to run when the field
// loses focus.
let mut ran_exit = false;
for ev in r.events_of(&som, "exit") {
ran_exit |= r.run(&ev, &value)?.is_some();
}
if ran_exit {
exited.push(som.clone());
}
for ev in r.events_of(&som, "validate") {
let Some(o) = r.run(&ev, &value)? else { continue };
if (o.result_bool == Some(false) || o.error.is_some()) && !value.trim().is_empty() {
@@ -992,14 +1311,62 @@ pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s
r.finish().map(|_| ())
}
/// A field gaining (`enter`) or losing (`exit`) focus: its scripts for that activity, then, when
/// they changed something, the calculations. Returns what changed; nothing (and no error) when
/// the field has no such script, so a focus change with nothing to run leaves the form alone.
pub(crate) fn on_focus(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<Changes, String> {
let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS);
let events = r.events_of(som, activity);
if events.is_empty() {
return Ok(Changes::default());
}
// The field's value, as the script's event sees it (`xfa.event.prevText`, `newText`).
let value = r.by_som().get(som).cloned().and_then(|name| r.fields().get(&name).map(|f| f.value.join("\n"))).unwrap_or_default();
for ev in events {
r.run(&ev, &value)?;
}
if !r.changes.is_empty() {
// The calculations that follow get a budget of their own.
r.clock = Clock::start(OPEN_BUDGET_MS.saturating_mul(u64::from(r.run.time_scale)));
r.calculates()?;
}
r.finish()
}
/// The SOM path of widget `widget` of field `field`: each radio button of a group has its own,
/// and a field's copies on several pages share one. `None` when the field has no widget
/// `widget`: the field's own path is only for a widget that records none, so a widget index
/// out of range never reaches the field (for a radio field, the group).
pub(crate) fn widget_som(doc: &pdfcraft_cos::Document, field: &pdfcraft_forms::Field, widget: usize) -> Option<String> {
let som_of = |obj: pdfcraft_cos::ObjRef| {
let object = doc.get(obj);
object.as_dict()?.get(pdfcraft_xfa::SOM_KEY).map(|s| doc.resolve(s))?.as_string().map(|s| s.to_text())
};
let w = field.widgets.get(widget)?;
som_of(w.obj).or_else(|| som_of(field.obj))
}
/// Does the live form give SOM path `som` an `activity` script?
pub(crate) fn has_event(doc: &pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str) -> bool {
// Page-master copies have a SOM path of their own on each page (`master[1]…`).
let uses = pdfcraft_xfa::page_area_uses(doc, tpl);
let live = pdfcraft_xfa::form_tree_paged(tpl, pdfcraft_xfa::data_of(doc).as_ref(), &pdfcraft_xfa::overrides(doc), &uses);
live.events.iter().any(|e| e.som == som && e.activity == activity)
}
/// The SOM path of a generated field whose template has a click script.
pub(crate) fn clickable_som(doc: &pdfcraft_cos::Document, tpl: &Template, field: &pdfcraft_forms::Field) -> Option<String> {
// Page-master copies have distinct PDF field names but share a SOM path. Reversing
// fields_by_som would lose every copy except the last: use the clicked field itself.
scripted_som(doc, tpl, field, "click")
}
/// The SOM path of a generated field whose template has an `activity` script.
pub(crate) fn scripted_som(doc: &pdfcraft_cos::Document, tpl: &Template, field: &pdfcraft_forms::Field, activity: &str) -> Option<String> {
// The field's own SOM path: each page-master copy has one of its own (`master[1]…`).
let object = doc.get(field.obj);
let som = object.as_dict()?.get(pdfcraft_xfa::SOM_KEY).map(|s| doc.resolve(s))?.as_string()?.to_text();
let live = pdfcraft_xfa::form_tree(tpl, pdfcraft_xfa::data_of(doc).as_ref(), &pdfcraft_xfa::overrides(doc));
live.events.iter().any(|e| e.som == som && e.activity == "click").then_some(som)
let uses = pdfcraft_xfa::page_area_uses(doc, tpl);
let live = pdfcraft_xfa::form_tree_paged(tpl, pdfcraft_xfa::data_of(doc).as_ref(), &pdfcraft_xfa::overrides(doc), &uses);
live.events.iter().any(|e| e.som == som && e.activity == activity).then_some(som)
}
/// The parsed template of a laid-out XFA form, kept for the document's life, when it has
@@ -1027,20 +1394,237 @@ pub(crate) fn mark_script_buttons(doc: &pdfcraft_cos::Document, fields: &mut [pd
mod tests {
use super::*;
/// A run of resets keeps each name once, in the order first given, and a reset of the whole
/// form takes in the rest, before or after it.
#[test]
fn resets_in_a_row_keep_each_name_once_and_a_whole_form_reset_wins() {
let v = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::<Vec<_>>();
let mut run = ResetRun::default();
run.add(&v(&["a", "b", "a"]));
run.add(&v(&["c", "b"]));
assert_eq!(run.names(), v(&["a", "b", "c"]).as_slice());
run.add(&[]);
assert!(run.names().is_empty());
run.add(&v(&["d"]));
assert!(run.names().is_empty(), "still the whole form");
let mut first = ResetRun::default();
first.add(&[]);
first.add(&v(&["a"]));
assert!(first.names().is_empty());
}
/// Adding a name to a run costs the same however many it holds: two runs of 60,000
/// distinct names merge at once (comparing each name with every kept one is 3.6 billion
/// comparisons).
#[test]
fn a_long_run_of_reset_names_merges_in_linear_time() {
let a: Vec<String> = (0..60_000).map(|i| format!("form1[0].a{i}")).collect();
let b: Vec<String> = (0..60_000).map(|i| format!("form1[0].b{i}")).collect();
let started = std::time::Instant::now();
let mut run = ResetRun::default();
run.add(&a);
run.add(&b);
assert_eq!(run.names().len(), 120_000);
assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed());
}
/// Matching objects against a reset's names costs the same however many names there are:
/// 5,000 objects against 50,000 names that miss them all is 250 million comparisons when
/// each object is compared with every name.
#[test]
fn matching_many_objects_against_many_reset_names_is_bounded() {
let names: Vec<String> = (0..50_000).map(|i| format!("form1.page1.other{i}")).collect();
let started = std::time::Instant::now();
let targets = ResetTargets::new(&names);
let hits = (0..5_000).filter(|i| targets.matches(&format!("form1[0].page1[0].table[0].row[{i}].item[0]"))).count();
assert_eq!(hits, 0);
assert!(targets.matches("form1[0].page1[0].other49999[0]"));
assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed());
}
#[test]
fn script_names_match_indexed_som_paths() {
let som_matches = |som: &str, name: &str| ResetTargets::new(&[name.to_string()]).matches(som);
assert!(som_matches("form[0].page1[0].qty[0]", "qty"));
assert!(som_matches("form[0].page1[0].qty[0]", "page1.qty"));
assert!(som_matches("form[0].page1[0].qty[0]", "xfa.form.form.page1.qty"));
assert!(som_matches("form[0].page1[0].qty[0]", "$form.form.page1.qty"));
assert!(som_matches("form[0].page1[0].table[0].row[2].amount[0]", "table"));
assert!(som_matches("form[0].page1[0].table[0].row[2].amount[0]", "table.row[0].amount"));
assert!(!som_matches("form[0].page1[0].qty[0]", "qt"));
assert!(!som_matches("form[0].page1[0].price[0]", "qty"));
assert!(!som_matches("form[0].page1[0].qty[0]", "page1.page1.qty"));
assert!(!som_matches("form[0].page1[0].qty[0]", " . "));
assert!(!ResetTargets::new(&[]).matches("form[0].qty[0]"));
let many = ResetTargets::new(&["price".into(), "page1.qty".into()]);
assert!(many.matches("form[0].page1[0].qty[0]") && many.matches("form[0].page2[0].price[0]"));
assert_eq!(
split_instance(&Template::default(), "form[0].table[0].row[2]").map(|(p, ps, n, i)| (p.len(), ps, n, i)),
Some((2, "form[0].table[0]".into(), "row".into(), 2))
);
}
/// The scripts' snapshot shares the template's property trees: the next script's, and the
/// next event's (a live form built again, from a copy of the template as overrides make
/// one), point at the trees the first converted rather than converting them again.
#[test]
fn snapshots_share_property_trees_across_runs_and_events() {
let fields: String = (0..2_000)
.map(|i| format!(r#"<field name="f{i}"><ui><choiceList/></ui><border><edge/><fill><color value="1,2,3"/></fill></border></field>"#))
.collect();
let xml = format!(
r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">{fields}</subform></template>"#
);
let (tpl, _) = pdfcraft_xfa::parse(&xml).unwrap();
let live = pdfcraft_xfa::form_tree(&tpl, None, &Default::default());
let a = to_js(&live.root, &tpl);
let b = to_js(&live.root, &tpl);
// A tree built again (with a script's overrides) is still the form's: same copies.
let copy = pdfcraft_xfa::apply_overrides(&tpl, &Default::default());
let c = to_js(&pdfcraft_xfa::form_tree(&copy, None, &Default::default()).root, &tpl);
assert_eq!(a.children.len(), 2_000);
for ((x, y), z) in a.children.iter().zip(&b.children).zip(&c.children) {
assert!(Arc::ptr_eq(&x.props, &y.props) && Arc::ptr_eq(&x.props, &z.props));
}
assert_eq!(a.children[0].props.elements[1].children[1].children[0].attrs, [("value".to_string(), "1,2,3".to_string())]);
}
/// A form's copies of its property trees are its own: kept for its later snapshots, and
/// dropped with its template when the form is closed, whatever other forms do.
#[test]
fn a_closed_forms_property_copies_go_with_its_template() {
let xml = |name: &str| {
format!(
r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb"><field name="{name}"><border><edge/></border></field></subform></template>"#
)
};
let (closed, _) = pdfcraft_xfa::parse(&xml("a")).unwrap();
let (open, _) = pdfcraft_xfa::parse(&xml("b")).unwrap();
let live = pdfcraft_xfa::form_tree(&closed, None, &Default::default());
let snapshot = to_js(&live.root, &closed);
let copy = Arc::downgrade(&snapshot.children[0].props);
drop(snapshot);
assert!(copy.upgrade().is_some(), "kept for the form's next snapshot");
// Another form's snapshot neither keeps nor needs this form's copies.
let other = to_js(&pdfcraft_xfa::form_tree(&open, None, &Default::default()).root, &open);
assert!(!Arc::ptr_eq(&other.children[0].props, &copy.upgrade().unwrap_or_default()));
drop(live);
drop(closed);
assert!(copy.upgrade().is_none(), "closing the form drops its copies at once");
}
/// A live form node whose property tree is told apart by an attribute.
fn marked(mark: &str) -> FormNode {
FormNode { props: Arc::new(Props { attrs: vec![("mark".into(), mark.into())], elements: Vec::new() }), ..Default::default() }
}
/// The copy made of a tree is the one every later snapshot of the form gets, and the map
/// holds one entry for it.
#[test]
fn later_snapshots_of_a_form_get_the_same_copy() {
let tpl = Template::default();
let node = marked("a");
let first = to_js(&node, &tpl);
let second = to_js(&node, &tpl);
assert!(Arc::ptr_eq(&first.props, &second.props));
let copies = props_copies(&tpl).expect("the engine's map");
assert_eq!(copies.len(), 1);
assert!(copy_of(&copies, &node.props).is_some_and(|c| Arc::ptr_eq(&c, &first.props)));
}
/// The map is keyed by a tree's address, so a tree freed and another made at the same
/// address must never get the first one's copy (the ABA case). An entry's weak reference
/// keeps the freed tree's allocation, so its address is not reused while the entry exists;
/// the next use of the map drops the entry, and only then can a new tree take the address,
/// with no entry left to find. Each new tree below is dropped at the end of its turn, so the
/// allocator is free to put the next one where it was.
#[test]
fn a_freed_trees_copy_never_goes_to_a_tree_at_its_address() {
let tpl = Template::default();
let old = marked("old");
let addr = Arc::as_ptr(&old.props) as usize;
drop(to_js(&old, &tpl));
drop(old);
let made: Vec<Arc<Props>> = (0..4_096).map(|_| Arc::new(Props::default())).collect();
assert!(made.iter().all(|p| Arc::as_ptr(p) as usize != addr), "the address is held while the entry exists");
drop(made);
assert!(props_copies(&tpl).is_some_and(|c| c.is_empty()), "the next use drops the entry");
for i in 0..4_096 {
let node = marked(&format!("new {i}"));
assert_eq!(to_js(&node, &tpl).props.attrs, node.props.attrs);
}
}
/// An entry under a tree's address that was made from another tree is not that tree's copy:
/// a lookup passes it by, and the snapshot's own lookup replaces it with the tree's copy.
#[test]
fn an_entry_made_from_another_tree_is_not_returned() {
let tpl = Template::default();
let node = marked("this");
let other = Arc::new(Props { attrs: vec![("mark".into(), "other".into())], elements: Vec::new() });
let gone = Arc::new(Props { attrs: vec![("mark".into(), "gone".into())], elements: Vec::new() });
let key = Arc::as_ptr(&node.props) as usize;
let mut copies = PropsCopies::new();
// Its tree is alive but elsewhere.
copies.insert(key, (Arc::downgrade(&other), Arc::new(convert_props(&other))));
assert!(copy_of(&copies, &node.props).is_none());
// Its tree is gone.
copies.insert(key, (Arc::downgrade(&gone), Arc::new(convert_props(&gone))));
drop(gone);
assert!(copy_of(&copies, &node.props).is_none());
// Through a snapshot: the stray entry is passed by and replaced.
props_copies(&tpl).expect("the engine's map").insert(key, (Arc::downgrade(&other), Arc::new(convert_props(&other))));
assert_eq!(to_js(&node, &tpl).props.attrs, node.props.attrs);
assert!(props_copies(&tpl).and_then(|c| copy_of(&c, &node.props)).is_some_and(|c| c.attrs == node.props.attrs));
}
/// Trees of other templates (the template read again) that a snapshot met leave the map once
/// they are gone: however many come and go, it holds the form's own trees and no more.
#[test]
fn the_map_does_not_grow_with_trees_that_are_gone() {
let xml = r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb"><field name="a"><border><edge/></border></field><field name="b"/></subform></template>"#;
let (tpl, _) = pdfcraft_xfa::parse(xml).unwrap();
let live = pdfcraft_xfa::form_tree(&tpl, None, &Default::default());
drop(to_js(&live.root, &tpl));
let own = props_copies(&tpl).map_or(0, |c| c.len());
assert!(own >= 3, "{own}");
for _ in 0..500 {
let (again, _) = pdfcraft_xfa::parse(xml).unwrap();
drop(to_js(&pdfcraft_xfa::form_tree(&again, None, &Default::default()).root, &tpl));
}
assert_eq!(props_copies(&tpl).map_or(0, |c| c.len()), own);
}
/// A field's click script reads its own and other objects' properties through a laid-out
/// dynamic form: template values, the spec's defaults, and a write read back, which changes
/// nothing on the form.
#[test]
fn a_click_script_reads_field_properties() {
let template = pdfcraft_xfa::fixtures::scripted_template()
.replace(
r#"<field name="hello" w="1in" h="0.3in"><ui><button/></ui>"#,
r#"<field name="hello" w="1in" h="0.3in"><ui><button/></ui><border><fill><color value="200,200,255"/></fill></border>"#,
)
.replace(
r#"xfa.host.messageBox("Hello " + qty.rawValue);"#,
r#"qty.validate.nullTest = "error"; xfa.host.messageBox([this.ui.oneOfChild.className, this.border.fill.color.value, this.border.presence, qty.ui.oneOfChild.className, qty.validate.message.text.value, qty.validate.nullTest, table.row.occur.max, more.ui.oneOfChild.className, this.isPropertySpecified("border"), qty.isPropertySpecified("border")].join("|"));"#,
);
let mut s = crate::Session::new();
let id = s.open("props.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
let _ = s.take_js_output(id);
let fields =
|s: &crate::Session| s.get(id).unwrap().form.iter().map(|f| (f.name.clone(), f.value.clone(), f.read_only())).collect::<Vec<_>>();
let before = fields(&s);
let out = s.run_javascript(id, "", Some("hello")).unwrap();
assert!(out.error.is_none(), "{out:?}");
assert_eq!(
out.alerts,
[pdfcraft_js::Message::plain("button|200,200,255|visible|numericEdit|Quantity must be 100 or less|error|-1|checkButton|true|false")]
);
assert!(s.take_js_output(id).errors.is_empty());
assert_eq!(fields(&s), before, "a property write changes nothing yet");
}
fn field(obj: pdfcraft_cos::ObjRef, kind: pdfcraft_forms::FieldKind, value: &[&str]) -> pdfcraft_forms::Field {
pdfcraft_forms::Field {
name: "f".into(),
+45 -11
View File
@@ -15,13 +15,10 @@ pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result<XfaLayout,
/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the
/// picture its signer's application drew.
pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> {
for f in pdfcraft_forms::fields(doc) {
if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) {
continue;
}
pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?;
}
Ok(())
let signed = |doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field| {
f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V"))
};
pdfcraft_forms::redraw_fields(doc, |doc, f| !signed(doc, f)).map_err(|e| e.to_string())
}
/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font
@@ -120,6 +117,18 @@ pub(crate) fn decode_data_file(bytes: &[u8]) -> Option<Result<String, String>> {
}
}
/// The on state a radio group's XFA data `item` selects: the button saving writes `item` for
/// (`/PCOn`, else its `/Opt` entry, else its state; `pdfcraft_xfa::radio_state_for_item`), so a
/// saved selection reopens on the same button; else `item` read as an export value, then as an
/// on-state name ([`pdfcraft_forms::Field::state_for_export`]), as data from elsewhere is; else
/// `item` itself.
fn radio_button(doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field, datum: Option<&pdfcraft_xfa::FieldDatum>, item: &str) -> String {
datum
.and_then(|d| pdfcraft_xfa::radio_state_for_item(doc, d, item))
.or_else(|| f.state_for_export(item).map(str::to_owned))
.unwrap_or_else(|| item.to_owned())
}
/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed,
/// buttons that did nothing but run scripts, and fields whose value differs from the form's
/// data (data PdfCraft could not read into them, now lost).
@@ -133,7 +142,16 @@ pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> R
match value {
pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(),
pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on,
pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(),
// The field holds an on-state name; the data names a button by its item. They agree
// when the data holds what saving writes (`/PCOn`, the `/Opt` entry or the state
// name), or names the same button as opening reads it ([`radio_button`]).
pdfcraft_xfa::FieldData::Radio(sel) => {
let state = f.value.first().map(String::as_str);
let datum = data.iter().find(|d| d.name == *name);
let same_button = sel.as_deref().map(|s| radio_button(doc, f, datum, s)).as_deref() == state;
let written = datum.map(|d| pdfcraft_xfa::radio_dataset_value(doc, d, &f.value.first().cloned()));
!same_button && written.as_deref() != Some(sel.as_deref().unwrap_or_default())
}
pdfcraft_xfa::FieldData::None => false,
}
})
@@ -217,12 +235,22 @@ pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> {
/// fields take the data's values.
pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> {
if let Some(tpl) = cx.xfa.clone() {
let mut run =
xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale };
let highlight = cx.highlight();
let mut run = xfa::XfaRun {
page_count: cx.pages,
out: &mut cx.xfa_out,
datasets: cx.xfa_datasets,
ran_away: false,
highlight,
time_scale: cx.xfa_time_scale,
};
let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run))
.unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})")));
cx.xfa_datasets = run.datasets;
cx.xfa_ran_away |= run.ran_away;
// The next edit of a batch reads the highlight this one's scripts set.
let on = run.highlight;
cx.set_highlight(on);
if let Err(e) = r {
cx.xfa_out.errors.push(e);
}
@@ -283,7 +311,13 @@ pub(crate) fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Resu
}
pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)),
pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)),
pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)),
// The data holds the item; the field takes the on state of the button saving writes it
// for ([`radio_button`]), which with `/Opt` may be a position (`1` is the state `/0`).
pdfcraft_xfa::FieldData::Radio(sel) => {
let datum = data.iter().find(|d| d.name == name);
let state = sel.map(|v| radio_button(doc, f, datum, &v));
(f.value.first() != state.as_ref()).then_some(FieldValue::Radio(state))
}
pdfcraft_xfa::FieldData::None => None,
};
if let Some(v) = new {
+5 -5
View File
@@ -21,7 +21,7 @@ fn names_lock() -> MutexGuard<'static, ()> {
/// `util.printd(fmt)` of 18 October 2026, then of what `util.scand(fmt, typed)` reads.
fn printd(fmt: &str, typed: &str) -> String {
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false };
let script = format!("event.value = util.printd('{fmt}', new Date(2026, 9, 18)) + '|' + util.printd('{fmt}', util.scand('{fmt}', '{typed}'));");
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
assert_eq!(o.error, None);
@@ -75,7 +75,7 @@ fn form_dates_follow_the_date_language() {
#[test]
fn an_english_weekday_before_a_shared_month_name_reads_as_english() {
let _names = names_lock();
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false };
let mar17 = DateTime { y: 2026, m: 3, d: 17, hh: 0, mm: 0, ss: 0 };
let mut bad = Vec::new();
for language in ["it", "es", "ro", "sq"] {
@@ -111,7 +111,7 @@ fn an_english_weekday_before_a_shared_month_name_reads_as_english() {
/// letter that writes a name, through the form path and through `util.scand`. Called from the
/// one test, which owns the process-wide names.
fn every_date_language_reads_back_what_it_writes() {
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false };
let mut bad = Vec::new();
for l in pdfcraft_engine::dates::DATE_LANGUAGES.iter() {
use_form_date_language(l.code);
@@ -241,7 +241,7 @@ fn fill(doc: &mut Document, name: &str) {
/// Fills `form`'s field `name` with `value` through the JavaScript runner.
fn fill_with(doc: &mut Document, name: &str, value: &str) {
let mut js = JsRunner::new(doc, "form.pdf");
let mut js = JsRunner::new(doc, "form.pdf", false);
pdfcraft_forms::set_value_with(doc, name, &FieldValue::Text(value.into()), &mut js).expect("fills");
}
@@ -319,7 +319,7 @@ fn an_english_run_that_prints_the_stored_value_is_drawn() {
/// What `util.scand(fmt, text)` reads, as `yyyy-mm-dd`, or `null`.
fn scanned(fmt: &str, text: &str) -> String {
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false, highlight: false };
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
assert_eq!(o.error, None);
+11 -7
View File
@@ -23,7 +23,8 @@ fn one_page() -> Document {
doc
}
/// Width and height of the dark pixels on page 1.
/// Width and height of the dark pixels on page 1: the black text, not the field's grey (0.55)
/// border, which would give the field's own outline.
fn ink_span(doc: &Document) -> (u32, u32) {
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
@@ -34,7 +35,7 @@ fn ink_span(doc: &Document) -> (u32, u32) {
for y in 0..p.height {
for x in 0..p.width {
let i = ((y * p.width + x) * 4) as usize;
if p.rgba[i] < 200 {
if p.rgba[i] < 100 {
ink = true;
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
@@ -44,15 +45,18 @@ fn ink_span(doc: &Document) -> (u32, u32) {
(x1 - x0, y1 - y0)
}
/// A tall, narrow field turned 90 degrees stays where it is and its text runs up its long side
/// (#4296), rather than the field turning into a short, wide one that cuts the text off.
#[test]
fn a_rotated_field_draws_along_the_vertical_edge() {
let mut doc = one_page();
add_field(&mut doc, 0, [10.0, 40.0, 90.0, 60.0], &NewField::Text { multiline: false }, Some("wide")).unwrap();
set_value(&mut doc, "wide", &FieldValue::Text("MMMMMM".into())).unwrap();
set_props(&mut doc, "wide", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap();
add_field(&mut doc, 0, [10.0, 40.0, 30.0, 120.0], &NewField::Text { multiline: false }, Some("tall")).unwrap();
set_value(&mut doc, "tall", &FieldValue::Text("MMMMMM".into())).unwrap();
set_props(&mut doc, "tall", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap();
let (wide, tall) = ink_span(&doc);
assert!(wide > tall, "upright text is a horizontal run: {wide}x{tall}");
set_props(&mut doc, "wide", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap();
set_props(&mut doc, "tall", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap();
let (wide, tall) = ink_span(&doc);
assert!(tall > wide, "rotated text is a vertical run: {wide}x{tall}");
// Six 12 pt M's are about 60 pt, 120 px at scale 2; the field is 20 pt (40 px) wide.
assert!(tall >= 100 && wide < 40, "the text does not run up the field: {wide}x{tall} px");
}
+1
View File
@@ -12,3 +12,4 @@ mod ocr_edit;
mod office_export_images;
mod private_image_resize;
mod private_text_glyphs;
mod xfa_repeat;
+16 -11
View File
@@ -419,18 +419,21 @@ fn a_formatting_change_redraws_the_whole_ocr_phrase() {
assert_eq!(drawn.join(" "), "John Smyth Example");
}
/// A layer whose words follow one another with no position of their own: a word that went would
/// move those after it, so keeping them isn't possible and the whole phrase is redrawn instead.
/// A layer whose words follow one another with no position of their own: a word that goes leaves
/// its advance behind (ISO 32000-2 §9.4.4), so the words after it stay where they were and are
/// kept as the scan, with nothing drawn.
#[test]
fn words_that_depend_on_the_one_before_are_redrawn_rather_than_moved() {
fn words_that_depend_on_the_one_before_are_kept_in_place() {
let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj ( ) Tj (Example) Tj ET");
let mut s = Session::new();
let id = s.open("relative.pdf", None, bytes, None).unwrap();
assert_eq!(texts(&s, id), ["John Smith Example"]);
let right = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Example")).map(|l| l.rect[2]);
let before = right(&s).unwrap();
edit_block(&mut s, id, 0, "John Example");
let (scan, drawn) = scan_and_drawn(&s, id);
assert!(scan.is_empty(), "no word is left to shift under the scan: {scan:?}");
assert_eq!(drawn, ["John Example"]);
assert_eq!((scan, drawn), (vec!["John".to_owned(), "Example".to_owned()], Vec::<String>::new()));
assert_eq!(right(&s), Some(before), "Example stays where it was");
}
/// The edit survives a save and reopen, and each piece can be edited again.
@@ -460,19 +463,21 @@ fn a_draft_with_the_same_words_changes_nothing() {
}
}
/// Visible text positioned after the end of an OCR word depends on that word. An edit may not
/// leave the word gone and the visible text somewhere else while calling the rest kept.
/// Visible text positioned after the end of an OCR word depends on that word. The word that goes
/// leaves its advance behind, so the visible text stays where it was and the word kept stays the
/// scan's.
#[test]
fn visible_text_that_follows_an_ocr_word_stops_it_being_kept_alone() {
fn visible_text_that_follows_a_removed_ocr_word_stays_in_place() {
let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj 0 Tr /F1 14 Tf ( Label) Tj ET");
let mut s = Session::new();
let id = s.open("label.pdf", None, bytes, None).unwrap();
let label = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Label")).map(|l| l.rect[0]);
assert_eq!(scan_and_drawn(&s, id).0, ["John Smith"]);
assert_eq!(scan_and_drawn(&s, id), (vec!["John Smith".to_owned()], vec!["Label".to_owned()]));
let before = label(&s).unwrap();
edit_block(&mut s, id, 0, "John");
let (scan, _) = scan_and_drawn(&s, id);
assert!(scan.is_empty(), "John can't stay alone when Label would move with it: {scan:?}, Label at {:?} from {before}", label(&s));
// Nothing new is drawn: the visible text is the page's own.
assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned()], vec!["Label".to_owned()]));
assert_eq!(label(&s), Some(before), "Label stays where it was");
}
/// A large word right before a small one, as OCR's sizes can make them: the large word's cover,
+83
View File
@@ -0,0 +1,83 @@
//! A dynamic XFA form whose button adds rows past fifty lays every row out and adds the pages
//! they need (XFA 3.3, "The occur element", p. 786: `max="-1"` sets no upper boundary).
use std::sync::Arc;
use pdfcraft_engine::Session;
/// A list whose `item` row (20pt, 36 to a page) repeats without bound, `data` rows in its
/// datasets, and a button that adds `add` rows in one click.
fn form(add: usize, data_rows: usize) -> Vec<u8> {
let data: String = (1..=data_rows).map(|i| format!("<item><qty>row {i}</qty></item>")).collect();
let datasets = if data_rows == 0 {
String::new()
} else {
format!(
r#"<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><page><list>{data}</list></page></form></xfa:data></xfa:datasets>"#
)
};
let xdp = format!(
r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
<pageSet><pageArea name="p"><occur max="-1"/><contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
<subform name="page" layout="tb">
<field name="more" w="100pt" h="20pt"><ui><button/></ui><caption><value><text>Add rows</text></value></caption>
<event activity="click"><script contentType="application/x-javascript">for (var i = 0; i &lt; {add}; i++) list._item.addInstance(1);</script></event></field>
<subform name="list" layout="tb">
<subform name="item" layout="tb"><occur min="1" max="-1"/><field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
</subform>
</subform>
</subform></template>
{datasets}
</xdp:xdp>"#
);
pdfcraft_xfa::fixtures::shell(&xdp)
}
fn rows(s: &Session, id: pdfcraft_engine::DocId) -> usize {
s.get(id).map_or(0, |d| d.form.iter().filter(|f| f.name.starts_with("qty")).count())
}
#[test]
fn a_button_adding_rows_past_fifty_lays_them_all_out() {
let mut s = Session::new();
let id = s.open("rows.pdf", None, Arc::new(form(59, 0)), None).expect("opens");
assert_eq!(rows(&s, id), 1);
assert_eq!(s.get(id).map(|d| d.info.pages.len()), Some(1));
let o = s.run_javascript(id, "", Some("more")).expect("the click runs");
assert_eq!(o.error, None);
// The button row plus 60 rows of 20pt: 61 rows at 36 a page is two pages.
assert_eq!(rows(&s, id), 60);
assert_eq!(s.get(id).map(|d| d.info.pages.len()), Some(2));
let out = s.take_js_output(id);
assert!(!out.console.iter().any(|l| l.contains("only the first")), "no row is cut: {:?}", out.console);
}
/// A table (`layout="table"`) placed at its own position in a positioned subform, its `item` row
/// repeating with `occur max` `max`, and five rows in its data (XFA 3.3, "Tables", p. 327).
fn positioned_table(max: &str) -> Vec<u8> {
let data: String = (1..=5).map(|i| format!("<item><qty>row {i}</qty></item>")).collect();
let xdp = format!(
r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
<pageSet><pageArea name="p"><contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
<subform name="page" w="540pt" h="700pt">
<subform name="table" layout="table" columnWidths="200pt" x="36pt" y="72pt">
<subform name="item" layout="row"><occur min="1" max="{max}"/><field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
</subform>
</subform>
</subform></template>
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><page><table>{data}</table></page></form></xfa:data></xfa:datasets>
</xdp:xdp>"#
);
pdfcraft_xfa::fixtures::shell(&xdp)
}
#[test]
fn a_table_in_a_positioned_subform_gets_a_row_per_data_row_up_to_its_max() {
let mut s = Session::new();
for (max, want) in [("-1", 5), ("3", 3)] {
let id = s.open("table.pdf", None, Arc::new(positioned_table(max)), None).expect("opens");
assert_eq!(rows(&s, id), want, "occur max {max}");
}
}
Loaded 100 of 353 files, more files were not shown because too many files have changed in this diff. Show more