diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 38c28a80ec..a8de85e07a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -45,7 +45,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/icons/chevron-up.svg` | Lucide icon "chevron-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/chevrons-left.svg` | Lucide icon "chevrons-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/chevrons-right.svg` | Lucide icon "chevrons-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/circle-check.svg` | Lucide icon "circle-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle-check.svg` | Lucide icon "circle-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers | | `assets/icons/circle-dashed.svg` | Lucide icon "circle-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/circle-dot.svg` | Lucide icon "circle-dot" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dot.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/circle-help.svg` | Lucide icon "circle-help" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-help.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | @@ -174,7 +174,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/icons/text-select.svg` | Lucide icon "text-select" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-select.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/trash-2.svg` | Lucide icon "trash-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/trash.svg` | Lucide icon "trash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/triangle-alert.svg` | Lucide icon "triangle-alert" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/triangle-alert.svg` | Lucide icon "triangle-alert" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers | | `assets/icons/type.svg` | Lucide icon "type" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/type.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/underline.svg` | Lucide icon "underline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/underline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/undo-2.svg` | Lucide icon "undo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/undo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | @@ -184,7 +184,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/icons/x.svg` | Lucide icon "x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/zoom-in.svg` | Lucide icon "zoom-in" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/zoom-out.svg` | Lucide icon "zoom-out" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths | +| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths; embedded into displayed signature status captions beyond WinAnsi by crates/sign/src/status_layers.rs | | `assets/fonts/Inter-Medium.ttf` | Inter Medium | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels | | `assets/fonts/Inter-SemiBold.ttf` | Inter SemiBold | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono Regular | The JetBrains Mono Project Authors | OFL-1.1 | https://github.com/JetBrains/JetBrainsMono | UI monospace font; demo PDF code | @@ -249,7 +249,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/icons/thumbs-up.svg` | Lucide icon "thumbs-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers | | `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs) | | `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | @@ -314,4 +314,4 @@ Not in this repository and never downloaded by it: compiled in only when the bui | Input | Option | Title | Author | Licence | Source | Attribution | Used for | |---|---|---|---|---|---|---|---| | `craft-i18n` | `CRAFT_I18N_DIR` | Interface translations: the craft-i18n catalogs (locales//common.tsv and pdfcraft.tsv) of the 34 languages PdfCraft offers besides English | The craft-i18n translators, including every translator of PdfCraft's former in-repo catalogs (crates/ui-egui/src/i18n/*.tsv, migrated at pdfcraft@ecdd1e6) | MIT OR Apache-2.0 | https://github.com/storytold/craft-i18n (pinned by commit in craft-i18n.rev, which CI, releases and the flatpak manifests read) | https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md | Interface text in languages other than English, embedded by crates/ui-egui/build.rs and looked up through craft-libs' craft-i18n crate; a build without CRAFT_I18N_DIR is English only. Clean-room translations from PdfCraft's English interface labels | -| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 | +| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); status captions of named-layer signatures in Arabic, Japanese, Thai and Devanagari, drawn as filled outlines into the display copy by crates/sign/src/status_layers.rs; the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 99073babb1..608bb601a5 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -425,7 +425,7 @@ source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icon licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" -usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers" sha256 = "201e790f3a05c65b2c4b0f952c9b9e1f35cd1c5b21698b3f4be131883fe90bc8" [[asset]] @@ -1844,7 +1844,7 @@ source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icon licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" -usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers" sha256 = "55aa37e0b73e7f8cd906952dbdeb96283c6595ca64aba240a17f3455cb989114" [[asset]] @@ -1954,7 +1954,7 @@ source = "https://github.com/rsms/inter" licence = "OFL-1.1" licence_file = "assets/fonts/OFL-Inter.txt" kind = "font" -usage = "UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths" +usage = "UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths; embedded into displayed signature status captions beyond WinAnsi by crates/sign/src/status_layers.rs" sha256 = "40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82" [[asset]] @@ -3004,7 +3004,7 @@ source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icon licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" -usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers" sha256 = "ef1fec17d88794ba6c34c5cf63bdadbd234b3d4dffbcc3d55468b47e3f398472" [[asset]] @@ -3142,7 +3142,7 @@ source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/ licence = "OFL-1.1" attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md" kind = "font" -usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1" +usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); status captions of named-layer signatures in Arabic, Japanese, Thai and Devanagari, drawn as filled outlines into the display copy by crates/sign/src/status_layers.rs; the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1" [[asset]] path = "crates/sign/tests/data/x509-rsa-sha1.pdf" diff --git a/Cargo.lock b/Cargo.lock index 0d315535a4..6710dddeea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4687,9 +4687,11 @@ dependencies = [ "p521", "pbkdf2", "pdfcraft-annot", + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "pdfcraft-testkit", "rc2", "ripemd", "rsa", diff --git a/ROADMAP.md b/ROADMAP.md index 820ceb0179..1de96a7cda 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -315,6 +315,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. +- **2026-10-20 (M9 #79 review, M2, M8):** text extraction reads a marked-content /ActualText that marks no glyph where it paints (paths and images, cut to what is visible, along the direction they are drawn), so words drawn as outlines can be found, copied and exported; OCR and scan field detection still count such a page as having no text of its own. Redaction strips /ActualText, /Alt, /E and /T from inline marked content around paint it clips, as it already did around paint it removed, so a partly redacted outlined word leaves no name in the file. Named-layer recognition also needs the layer form to draw n1 and n3. Estimates unchanged. - **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged. - **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged. - **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged. @@ -405,6 +406,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged. - **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged. - **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged. +- **2026-10-16 (M9, #79):** a signature appearance that follows the named-layer convention (its /N draws one Form that holds and draws n1 and n3) shows the signature's real state: n1, n3 and n4 are redrawn in the display copy with the panel's icons and a caption in the interface language (Helvetica; else shaped in embedded Inter, or in a craft-fonts face for Arabic, Japanese and Telugu; else English, as Chinese always is). Exports, both print modes and the CLI's render show the redrawn state; saves never do. Every signature keeps its badge, since a document can clip or cover its own layers. Estimates unchanged. +- **2026-10-16 (M9):** the page validation overlay moves to the widget's bottom-right corner as it lies on the page (so it turns with the page and the view) and takes a quarter of the widget's shorter side, at most 48 screen points, so it annotates the signer's artwork from a corner instead of covering the middle of it; a field too small for a legible mark keeps its outline, a rectangle the screen cannot place is not drawn at all, and the whole widget answers the pointer. Estimates unchanged. - **2026-10-16 (M5, #2669, item 5):** The Comments panel groups replies by name and counts all page roots once per frame. Estimates unchanged. - **2026-10-16 (M7, #2140):** Header & footer (and Bates numbering, which uses the same dialog) can shrink the page content so the header and footer don't cover it: the original content is scaled evenly into the band they leave free, centred across, also on rotated pages; Remove and Update take the scaling away, and margins that leave no room are refused. Dialog checkbox with a preview of the band, `shrink` on `doc_header_footer`, all catalogs. Annotations and added text and images are not moved yet. Estimates unchanged. - **2026-10-20 (M14, Spanish catalog gaps):** `es.tsv` gains the 143 `(context, English)` keys the other catalogs carried and it was missing, so Spanish covers the whole bundled key union like Lithuanian and Vietnamese: calendar months and weekdays, the `certificate` context, X.509 key usages, signature-revision and digital-ID lines, password validation, eight plural messages, the combine-from-folder strings, the rendering and stale-file notices and the shortcut-spelled tooltips. Counts in `localization-parity.md` re-measured against the current union (`vi` restated against it for the same reason). Status stays `partial`: no translated help, and OS details stay outside the catalog. Estimates unchanged. @@ -793,6 +796,7 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | +| 2026-10-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | @@ -807,6 +811,8 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-10-18 | minor | M1, #1143: a live reference may match the xref generation or a same-number header at its indexed offset; neither-match and free slots remain null. Bounded repair diagnostics and synthetic cache, free-slot, edit and classic/packed save regressions; estimates unchanged. | | 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | | 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. | +| 2026-10-16 | minor | M9 #79: named-layer signature appearances show the signature's real state in the display copy, exports and both print modes, never in saves; captions follow the interface language. Estimates unchanged. | +| 2026-10-16 | minor | M9: the page validation overlay moves to the widget's bottom-right corner as it lies on the page and stops growing past 48 screen points, so it annotates the signer's artwork from a corner instead of covering the middle of it; a field under 32 screen points keeps its outline. Estimates unchanged. | | 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. | | 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. | | 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index 9d0b2057f0..3118c901b4 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -366,8 +366,9 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> { Some("pam") => None, _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()), }; - // The app renders a display copy that draws comments missing their /AP streams; the CLI - // must agree with it (and with independent viewers), while keeping the file it reads intact. + // The app renders a display copy that draws comments missing their /AP streams and the state + // of named-layer signatures (here with no trusted certificates); the CLI must agree with it + // (and with independent viewers), while keeping the file it reads intact. let bytes = read(path)?; let password = flag(args, "--password"); // The renderer takes only the user password of R2–R4 files; `Session::open` and the display diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index 743a36ff56..df0fd643fb 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -119,6 +119,7 @@ impl Automation { "page": s.page.map(|p| p + 1), "rect": rect, "visible": s.visible, + "verdict_shown": s.verdict_shown, "revision": s.revision, "sub_filter": s.sub_filter, "algorithm": s.algorithm, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 674b00f329..99f27bba61 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -896,7 +896,7 @@ pub fn tools() -> Vec { comment_ref(json!({ "status": { "type": "string", "enum": ["none", "accepted", "rejected", "cancelled", "completed"] }, "author": { "type": "string" } })), &["doc", "status"], )), - t("sign_list", "List and validate signatures", "Every signature field with its validation: status (valid, unknown = intact but the signer isn't trusted, invalid, unsigned), signer and certificate, date, reason, location, certification level, page and rect, the revision it covers and changes made after signing (none, allowed, disallowed), with Acrobat-style explanations.") + t("sign_list", "List and validate signatures", "Every signature field with its validation: status (valid, unknown = intact but the signer isn't trusted, invalid, unsigned), signer and certificate, date, reason, location, certification level, page and rect, whether PdfCraft draws that state into the signature's own appearance as displayed (verdict_shown; never written to the file), the revision it covers and changes made after signing (none, allowed, disallowed), with Acrobat-style explanations.") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), t("sign_id_create", "Create a digital ID", "Create a self-signed digital ID and save it as a password-protected .p12 file (Acrobat: Configure a new digital ID ▸ Create a new digital ID ▸ Save to file). Default key: 2048-bit RSA, valid 5 years.").with(schema( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index dc5dec0521..1b5ad777a1 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6899,6 +6899,43 @@ fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn named_layer_verdicts_are_reported_and_rendered_through_tools() { + use pdfcraft_cos::{Dict, Document, Lexer, ObjRef, Object, SaveOptions, Stream}; + let dir = workdir("named-layer-verdict"); + let mut cos = Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let dict = |text: &str| Lexer::new(text.as_bytes(), 0).object().unwrap().as_dict().unwrap().clone(); + let form = |resources: Dict, ops: &str| { + let mut d = dict("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] >>"); + d.set(b"Resources".to_vec(), resources); + Stream::from_raw(d, ops.as_bytes().to_vec()) + }; + let blank = cos.add(form(Dict::new(), "")); + let layers = dict(&format!("<< /XObject << /n1 {blank} /n3 {blank} >> >>")); + let layer_form = cos.add(form(layers, "/n1 Do /n3 Do")); + let normal = cos.add(form(dict(&format!("<< /XObject << /FRM {layer_form} >> >>")), "/FRM Do")); + // Deliberately malformed CMS: an invalid signature still gets the redrawn invalid state. + let value = cos.add(dict("<< /Type /Sig /SubFilter /ETSI.CAdES.detached /ByteRange [0 0 0 0] /Contents <00> >>")); + let widget = cos + .add(dict(&format!("<< /FT /Sig /T (Signature1) /Subtype /Widget /F 4 /Rect [50 50 150 150] /P 4 0 R /V {value} /AP << /N {normal} >> >>"))); + cos.update_dict(ObjRef::new(4, 0), |p| p.set(b"Annots".to_vec(), vec![Object::Ref(widget)])).unwrap(); + cos.update_dict(cos.root().unwrap(), |root| root.set(b"AcroForm".to_vec(), dict(&format!("<< /Fields [{widget}] >>")))).unwrap(); + let original = pdfcraft_cos::write_full(&cos, &SaveOptions::default()).unwrap(); + std::fs::write(dir.join("named.pdf"), &original).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "named.pdf"}))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "sign_list", json!({"doc": doc})); + assert_eq!(list["signatures"][0]["status"], "invalid"); + assert_eq!(list["signatures"][0]["verdict_shown"], true); + let rendered = a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap(); + let Content::Png { data, .. } = &rendered[0] else { panic!("expected a PNG") }; + let pixels = image::load_from_memory_with_format(data, image::ImageFormat::Png).unwrap().to_rgba8(); + let red = pixels.pixels().filter(|p| p.0.iter().zip([0xD7, 0x37, 0x3F]).all(|(a, b)| a.abs_diff(b) < 30)).count(); + assert!(red > 100, "the invalid state is drawn: {red} red pixels"); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "saved.pdf", "full": false})); + assert_eq!(std::fs::read(dir.join("saved.pdf")).unwrap(), original, "the drawn state never enters saves"); +} + #[test] fn digital_ids_signing_and_validation_through_tools() { let dir = workdir("signing"); @@ -6957,6 +6994,8 @@ fn sign_document_and_verify_trust(a: &mut Automation, dir: &Path) -> u64 { assert_eq!(t["trusted"].as_array().unwrap().len(), 1); let list = ok(a, "sign_list", json!({ "doc": doc })); assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid"))); + // PdfCraft's own appearance has no status layers to draw into: only the page badge shows it. + assert_eq!(list["signatures"][0]["verdict_shown"], false); ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" })); let s = &ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]; assert_eq!((s["status"].as_str(), s["modification"].as_str()), (Some("valid"), Some("allowed"))); diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 03c4f7ace8..5405467edd 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -8,7 +8,8 @@ use pdfcraft_render::{DocInfo, RenderConfig, RenderPool}; use crate::history::Editor; use crate::{ CalculationOrder, CheckStyle, DocId, Edit, EditError, EditableObject, FormFieldKind, Hidden, InitialView, Label, MarkKind, SecuritySummary, - SignatureInfo, TabOrder, TrustStore, XfaLayout, a11y, form_appearance, guard, js, measure, optimize, renderer_password, unrestricted, + SignatureInfo, StatusCaptions, TabOrder, TrustStore, XfaLayout, a11y, form_appearance, guard, js, measure, optimize, renderer_password, + signatures_of, unrestricted, }; #[cfg(test)] use crate::{record_link_scan, record_marker_scan}; @@ -19,8 +20,9 @@ pub struct Document { pub path: Option, /// The working file: what Save writes. pub bytes: Arc>, - /// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances - /// drawn for display only for comments that have none ([`display_bytes`]). Never saved. + /// What is displayed (the page view, `page_render`, page images): `bytes`, plus what is drawn + /// for display only ([`display_bytes`]): appearances for comments that have none, and the + /// states of named-layer signatures. Never saved. pub display: Arc>, pub info: DocInfo, pub renderer: RenderPool, @@ -41,6 +43,9 @@ pub struct Document { /// Signature fields, validated against the session's trust store (Signatures panel). pub signatures: Arc>, pub(crate) trust: Arc, + /// The words signature status layers are drawn with (the session's, see + /// [`Session::set_signature_captions`](crate::Session::set_signature_captions)). + pub(crate) captions: Arc, pub(crate) sig_cache: Arc, pub(crate) editor: Option, pub(crate) config: RenderConfig, @@ -649,25 +654,84 @@ pub(crate) fn is_placeholder_title(title: &str) -> bool { address || t == "untitled" } -/// The working file plus appearances for comments that have none, and for form fields the file -/// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the -/// viewer and never enter these bytes, which image/text exports and Print as Image also consume. -pub(crate) fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { +/// What is displayed: the working file plus, in memory only and never saved, the appearances +/// PdfCraft draws for comments that have none ([`pdfcraft_annot::with_missing_appearances`]) and +/// for form fields the file leaves to the viewer (#3470), and the signature states drawn into +/// named-layer signature appearances ([`pdfcraft_sign::show_status_layers`]). The working file is +/// unchanged, and so is everything that reads it (saving, signatures, …). Image and text exports +/// and Print as Image read these bytes, so they show the page as displayed; validation badges +/// belong to the viewer and never enter them. +/// +/// `signatures` is input and output: each one whose state gets drawn comes back marked +/// (`verdict_shown`). A mark describes the bytes returned, so every mark is cleared first and a +/// copy that cannot be written takes its marks with it. +pub(crate) fn display_bytes( + editor: Option<&mut Editor>, + bytes: &Arc>, + signatures: &mut [SignatureInfo], + captions: &StatusCaptions, +) -> Arc> { + for sig in signatures.iter_mut() { + sig.verdict_shown = false; + } let Some(e) = editor else { return bytes.clone() }; let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances); let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances); - let Some(display) = fields.or(comments) else { + if let Some(mut display) = fields.or(comments) { + // Written whether or not a signature changed: the copy exists for the comments and fields. + let _ = pdfcraft_sign::show_status_layers(&mut display, signatures, captions); + return write_display_copy(e, bytes, &display, signatures); + } + // No comment or field needed an appearance, so there is no copy yet: build one only when a + // signature's state would change it. + if !pdfcraft_sign::has_status_layers(&e.cos, signatures) { return bytes.clone(); - }; - match write_incremental(&display, &SaveOptions::default()) { + } + let mut display = e.cos.clone(); + if !pdfcraft_sign::show_status_layers(&mut display, signatures, captions) { + return bytes.clone(); + } + write_display_copy(e, bytes, &display, signatures) +} + +/// Write the display copy, or keep the working file when it cannot be written. The page then +/// shows the file's own artwork, so the marks are cleared. Losing the copy is a repair, so it is +/// logged, once however many refreshes lose it again. +pub(crate) fn write_display_copy( + e: &mut Editor, + bytes: &Arc>, + display: &pdfcraft_cos::Document, + signatures: &mut [SignatureInfo], +) -> Arc> { + match write_incremental(display, &SaveOptions::default()) { Ok(written) => Arc::new(written), Err(err) => { - e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})")); + for sig in signatures.iter_mut() { + sig.verdict_shown = false; + } + let line = format!("the display copy could not be written, so pages show the file as stored ({err})"); + if !e.cos.repair_log().contains(&line) { + e.cos.note_repair(line); + } bytes.clone() } } } +/// Carry the marks across a revalidation that leaves the display copy alone, when it may: only +/// when every verdict is the one the copy was drawn with. Returns whether it did; otherwise the +/// copy has to be drawn again. +pub(crate) fn carry_verdict_marks(previous: &[SignatureInfo], next: &mut [SignatureInfo]) -> bool { + let unchanged = previous.len() == next.len() + && previous.iter().zip(next.iter()).all(|(p, n)| p.field == n.field && p.revision == n.revision && p.status == n.status); + if unchanged { + for (p, n) in previous.iter().zip(next.iter_mut()) { + n.verdict_shown = p.verdict_shown; + } + } + unchanged +} + /// `comments` (the display copy with comment appearances, if it needed any; else the document /// itself) with its form fields drawn from their values where the file leaves that to the viewer. fn with_field_appearances(comments: Option, doc: &pdfcraft_cos::Document) -> Option { @@ -694,17 +758,28 @@ pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Documen ] } -/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for -/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page -/// images agree with the app and independent viewers. It never changes the source document, and an -/// unparseable file, a write failure or a parser panic falls back to the original bytes. +/// The copy PDFCraft displays when opening `bytes` with no trusted certificates and English +/// captions ([`display_bytes`]): appearances drawn, in memory only, for comments that have none +/// and form fields the file leaves to the viewer, and the state of each named-layer signature. +/// Render paths outside the app, such as the CLI, use this so their page images agree with the app +/// and independent viewers. It never changes the source document: a file with nothing to draw, an +/// unparseable file, a write failure or a parser panic gives back the original bytes. pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc> { guard(|| { - pdfcraft_cos::Document::open_with_password(bytes.clone(), password) - .ok() - .and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos)) - .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) - .map(Arc::new) + let cos = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).ok()?; + let mut signatures = signatures_of(&cos, &bytes, &TrustStore::default(), &pdfcraft_sign::DigestCache::default()); + let layered = pdfcraft_sign::has_status_layers(&cos, &signatures); + let (mut display, added) = match with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos) { + Some(display) => (display, true), + None if layered => (cos, false), + None => return None, + }; + // As in the app, a layer recognised but not drawn after all makes no copy. + let drawn = layered && pdfcraft_sign::show_status_layers(&mut display, &mut signatures, &StatusCaptions::default()); + if !(added || drawn) { + return None; + } + write_incremental(&display, &SaveOptions::default()).ok().map(Arc::new) }) .ok() .flatten() diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 1330faf272..4512bcef43 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1,8 +1,8 @@ //! Export a PDF ▸ Image and Text (execution plan M10.3, the first formats). //! //! [`Exporter`] renders pages to PNG, JPEG or TIFF at a resolution and extracts reading-order -//! text, from a -//! document's working file (so unsaved edits and hidden layers are respected, as on screen). +//! text, from the copy a document displays ([`Document::export_source`]): its working file, so +//! unsaved edits and hidden layers are respected, with what is drawn for display only. use std::collections::BTreeMap; use std::panic::{AssertUnwindSafe, catch_unwind}; @@ -67,9 +67,10 @@ pub struct ExportSource { impl Document { /// What an export reads, for exporting on another thread. These are the displayed - /// bytes, so they may carry missing comment appearances PdfCraft drew. Signature - /// artwork stays original; viewer validation badges are not document content and - /// do not enter image/text exports or Print as Image. Saving uses the working file. + /// bytes, so they may carry appearances PdfCraft drew that the file itself does not hold: + /// missing comment appearances, and the signature states of named-layer signature + /// appearances. Exports and Print as Image follow the page as shown; the viewer's badges + /// are not document content and never enter them. Saving uses the working file. pub fn export_source(&self) -> ExportSource { ExportSource { bytes: self.display.clone(), diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index cc78785815..f581504204 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -429,12 +429,16 @@ impl Session { let mut out = Vec::new(); for page in list { let Some(info) = doc.info.pages.get(page) else { continue }; - let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); let shapes = pdfcraft_forms::detect::page_shapes(cos, page); let existing: Vec<[f64; 4]> = doc.form.iter().flat_map(|f| f.widgets.iter()).filter(|w| w.page == Some(page)).map(|w| w.rect).collect(); let (found, turn) = if shapes.boxes.is_empty() && shapes.rules.is_empty() && crate::scan_fields::is_scan(doc, page, info) { - self.detect_on_scan(id, &mut r, page, res.text.as_deref(), &existing, &taken) + // Labels come from the page's own text. Words that only /ActualText names (a stamp + // drawn as outlines) are no labels, and leave the scan to be read. + let own = crate::ocr::own_text(&mut r, page).ok(); + self.detect_on_scan(id, &mut r, page, own.as_ref(), &existing, &taken) } else { + let res = + r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); let words = res.text.map(|t| detection_words(&t, info)).unwrap_or_default(); (pdfcraft_forms::detect::detect(&words, &shapes, &existing, &taken), 0) }; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 378f9483b2..4fb8d8a472 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -130,7 +130,7 @@ pub use pdfcraft_redact::patterns::{ }; pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; pub use pdfcraft_sign as sign; -pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; +pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, StatusCaptions, TrustStore}; pub use pdfcraft_xfdf::Format as DataFormat; pub use pdfcraft_xfdf::{Outline as CommentOutline, comment_count, comment_outlines, comments_bounds, is_xfdf_comments}; @@ -141,7 +141,7 @@ pub use comments::{SummarySort, comment_kind, comment_summary, comment_type_name #[cfg(test)] use document::page_runs; pub use document::{Document, EditNotice, TextNotice, display_bytes_for, render_password_for}; -use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; +use document::{carry_verdict_marks, display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; pub use fill_sign::{displayed_offset, text_box_at, typed_signature_graphic, typed_signature_shape}; diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index 18ea331d41..c4854d7e9f 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -11,6 +11,7 @@ use std::sync::{Arc, Mutex}; #[cfg(not(target_arch = "wasm32"))] use std::time::{Duration, Instant}; +use pdfcraft_render::text::TextFilter; use pdfcraft_render::{PageInfo, PageRenderer, RenderConfig, RenderRequest, RequestKind}; pub use pdfcraft_ocr::{ @@ -161,6 +162,18 @@ const ALREADY_ON_PAGE: f32 = 0.5; /// is a resolution on paper this size or smaller. const SHEET: f32 = 842.0; +/// The text of `page`'s own: its glyphs, without the words drawn as outlines or pictures that only +/// `/ActualText` names. +pub(crate) fn own_text(r: &mut PageRenderer, page: usize) -> Result { + r.filtered_text(page, TextFilter { painted: true, ..Default::default() }) +} + +/// Whether `page` has text of its own ([`own_text`]). A stamp drawn as outlines on a scan still +/// leaves the scan to read; its words are not read twice, since they count as already on the page. +fn has_own_text(r: &mut PageRenderer, page: usize) -> bool { + own_text(r, page).is_ok_and(|t| t.glyphs.iter().any(|g| !g.text.trim().is_empty())) +} + /// How much of `word` the `text` boxes cover, from 0 to 1. Boxes are `[x0, y0, x1, y1]` in page /// view space. Overlaps between the text boxes are counted twice (glyphs rarely overlap), so the /// sum is capped at the whole word. @@ -702,13 +715,14 @@ impl OcrJob { } let Some(info) = self.infos.get(page) else { continue }; let skip = |why: &str| OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(why.into()), notes: Vec::new() }; - // The boxes of the page's own text (an earlier OCR layer's included), in view space. + // The boxes of all text on the page, in view space: an earlier OCR layer's, and words + // only /ActualText names, so neither is read again. let existing: Vec<[f32; 4]> = r .render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() }) .text .map(|t| t.glyphs.iter().filter(|g| !g.text.trim().is_empty()).map(|g| g.rect).collect()) .unwrap_or_default(); - if self.settings.skip_text_pages && !self.settings.force_ocr && !existing.is_empty() { + if self.settings.skip_text_pages && !self.settings.force_ocr && !existing.is_empty() && has_own_text(&mut r, page) { out.push(skip("the page already contains text")); continue; } @@ -1118,6 +1132,34 @@ mod tests { use super::*; use crate::export; + /// One 300 × 300 page drawing `content`, with Helvetica as `/F1`. + fn page(content: &str) -> Arc> { + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + Arc::new(pdf.into_bytes()) + } + + #[test] + fn a_stamp_drawn_as_outlines_leaves_a_scan_to_read() { + let stamp = page("/Span <> BDC 100 40 60 20 re f EMC"); + let mut r = PageRenderer::new(stamp, RenderConfig::default()); + assert!(!has_own_text(&mut r, 0), "words only /ActualText names are no text of the page's own"); + // They still count as on the page, so reading the scan doesn't add them twice. + let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, scale: 1.0, ..Default::default() }).text.unwrap(); + assert_eq!(text.plain_text(), "APPROVED"); + } + + #[test] + fn printed_glyphs_count_as_own_text() { + let mut r = PageRenderer::new(page("BT /F1 12 Tf 20 150 Td (Total) Tj ET"), RenderConfig::default()); + assert!(has_own_text(&mut r, 0)); + } + /// #1028: a recognised word over the page's own text is that text again; one in an empty /// part of the page (a picture) is new. #[test] diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index 32db07abee..80a5ad30c7 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -7,8 +7,8 @@ use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect}; use crate::history::{Editor, Keys, TextBlocksCache}; use crate::{ - AppearanceCache, DocId, Document, Session, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, display_config, guard, info_fields, js, - note_warnings, recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets, + AppearanceCache, DocId, Document, Session, StatusCaptions, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, display_config, guard, + info_fields, js, note_warnings, recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets, }; /// Immutable session state needed while a document is prepared away from its owning thread. @@ -17,6 +17,7 @@ pub struct OpenContext { now: Option, js_off: bool, trust: Arc, + captions: Arc, xfa_time_scale: u32, } @@ -54,7 +55,13 @@ impl OpenContext { impl Session { /// Snapshot the session state that affects open-time scripts, dates and signature trust. pub fn open_context(&self) -> OpenContext { - OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) } + OpenContext { + now: self.now(), + js_off: self.js_off, + trust: self.trust.clone(), + captions: self.captions.clone(), + xfa_time_scale: self.xfa_time_scale.unwrap_or(1), + } } /// Prepare a document without adding it to this session. @@ -74,6 +81,14 @@ impl Session { let id = DocId(self.next_id); let mut document = prepared.document; document.id = id; + // Trust or the interface language may have changed while it was prepared on another + // thread. + if !Arc::ptr_eq(&document.trust, &self.trust) { + document.captions = self.captions.clone(); + Self::revalidate(&mut document, self.trust.clone()); + } else if *document.captions != *self.captions { + Self::draw_captions(&mut document, self.captions.clone()); + } self.docs.push(document); id } @@ -253,7 +268,7 @@ impl Session { // XFA notes survive the document being re-read after edits. let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; let mut document = - Self::build_document(&context.trust, DocumentOpenData { name, path, bytes, info, structure: cos, render_password, password, xfa }); + Self::build_document(context, DocumentOpenData { name, path, bytes, info, structure: cos, render_password, password, xfa }); // A script that ran away on open turns the form's scripts off for this document. document.xfa_template = if ran_away { None } else { xfa_template }; document.js_output.append(script_output); @@ -266,7 +281,7 @@ impl Session { } /// The last step of opening: build the document record and register it. - fn build_document(trust: &Arc, data: DocumentOpenData<'_>) -> Document { + fn build_document(context: &OpenContext, data: DocumentOpenData<'_>) -> Document { let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data; let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; let (mut editor, read_only_reason) = match cos { @@ -293,7 +308,7 @@ impl Session { Err(_) => (None, Some("the document structure could not be read for editing".into())), }; let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); - let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); + let mut signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, &context.trust, &sig_cache)).unwrap_or_default(); // The inspection reads only `/Info`; a file stating its title or author only in XMP still // shows them (#2373). if let Some(e) = &editor { @@ -303,7 +318,7 @@ impl Session { } } } - let display = display_bytes(editor.as_mut(), &bytes); + let display = display_bytes(editor.as_mut(), &bytes, &mut signatures, &context.captions); let renderer = RenderPool::new(display.clone(), render_threads(), display_config(&config)); let (form, adopted) = editor .as_ref() @@ -341,7 +356,8 @@ impl Session { marker_scan: OnceLock::new(), links: OnceLock::new(), signatures: Arc::new(signatures), - trust: trust.clone(), + trust: context.trust.clone(), + captions: context.captions.clone(), sig_cache, editor, config, diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 9a4cb04dac..e5029ccbd6 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -11,11 +11,11 @@ use crate::edit::{EditCtx, check_permission, run_edit}; use crate::history::{Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; use crate::{ BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hidden, ImageResolution, - LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, SummarySort, TrustStore, - XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, comment_list, comment_summary, decode_text, display_bytes, display_config, guard, - info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_raster, - render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, - use_layer_choices, writable, xfa, xfa_sync_datasets, + LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, StatusCaptions, SummarySort, + TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list, comment_summary, decode_text, + display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, + parse_ymd, print, print_raster, render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, + sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, xfa_sync_datasets, }; mod editing; @@ -36,6 +36,8 @@ pub struct Session { utc_offset: Option i64>, /// Certificates trusted for signing (Acrobat: Trusted Certificates). pub(crate) trust: Arc, + /// The words signature status layers are drawn with, in the interface language. + pub(crate) captions: Arc, /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). pub(crate) js_off: bool, /// Preferences ▸ Date format, when not the default (see [`dates`]). diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index be7fd5aca5..8ab3ca22fa 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -2,9 +2,9 @@ use super::check_refresh_panic; use super::{ Arc, DocId, Document, Edit, EditCtx, EditError, Label, MAX_UNDO, ObjectKind, OnceLock, RenderPool, SaveOptions, Scope, Session, - XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, check_permission, comment_list, display_bytes, display_config, guard, info_fields, inspect, js, keys_after, - note_warnings, render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices, - uses_scripts, write_incremental, xfa, xfa_sync_datasets, + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, carry_verdict_marks, check_permission, comment_list, display_bytes, display_config, guard, info_fields, + inspect, js, keys_after, note_warnings, render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, + undrawn_notices, uses_scripts, write_incremental, xfa, xfa_sync_datasets, }; use crate::{EditNotice, MarkKind}; @@ -342,7 +342,14 @@ impl Session { #[cfg(test)] check_refresh_panic(); if !doc.signatures.is_empty() { - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + let mut signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + // The display copy is kept for a metadata edit, and so are its marks, unless the + // edit changed a verdict (a certification can forbid it): the page then draws the + // new one. + if !carry_verdict_marks(&doc.signatures, &mut signatures) { + doc.display = display_bytes(Some(editor), &bytes, &mut signatures, &doc.captions); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + } doc.signatures = Arc::new(signatures); } doc.bytes = bytes; @@ -386,8 +393,8 @@ impl Session { } doc.info.file_size = bytes.len(); doc.form = form; - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &bytes); + let mut signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes, &mut signatures, &doc.captions); doc.signatures = Arc::new(signatures); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); doc.bytes = bytes; @@ -444,8 +451,8 @@ impl Session { doc.marker_scan = OnceLock::new(); } doc.info.file_size = bytes.len(); - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &bytes); + let mut signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes, &mut signatures, &doc.captions); doc.signatures = Arc::new(signatures); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); doc.bytes = bytes; @@ -467,6 +474,13 @@ impl Session { doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone()); doc.display = original.clone(); doc.bytes = original; + // The page shows the original's own artwork again, so nothing counts as drawn, even if + // the refresh that follows fails. + if doc.signatures.iter().any(|s| s.verdict_shown) { + for sig in Arc::make_mut(&mut doc.signatures) { + sig.verdict_shown = false; + } + } #[cfg(test)] check_refresh_panic(); } @@ -528,8 +542,8 @@ impl Session { if invalidate_links { doc.links = OnceLock::new(); } - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.display = display_bytes(Some(editor), &bytes); + let mut signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.display = display_bytes(Some(editor), &bytes, &mut signatures, &doc.captions); doc.signatures = Arc::new(signatures); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); doc.bytes = bytes; diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 44bed85cde..f4e25bc2f6 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -170,6 +170,11 @@ impl Session { if !doc.config.layers.is_empty() { set_default_layer_state(&mut cos, &doc.info.layers); } + // Signature states as the screen shows them, so not when its display copy could not be + // written. This copy is only printed, never saved. + if doc.signatures.iter().any(|s| s.verdict_shown) { + let _ = pdfcraft_sign::show_status_layers(&mut cos, &mut doc.signatures.to_vec(), &doc.captions); + } pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) } diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index 1e6918f6a6..c67cd391b9 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -1,6 +1,6 @@ use super::{ - Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes, - display_config, guard, render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets, + Arc, DocId, Document, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, StatusCaptions, TrustStore, + display_bytes, display_config, guard, render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets, }; impl Session { @@ -242,17 +242,55 @@ impl Session { pub fn set_trusted_certificates(&mut self, certs: Vec) { self.trust = Arc::new(TrustStore { certs }); for doc in &mut self.docs { - doc.trust = self.trust.clone(); - let Some(editor) = doc.editor.as_mut() else { continue }; - // Keep verdict refresh and renderer refresh together. Viewer caches are - // invalidated by the UI trust path; trust alone changes no document artwork. - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &doc.bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + Self::revalidate(doc, self.trust.clone()); } } + /// Validate `doc`'s signatures against `trust`, and rebuild its display copy and renderer + /// with them: the verdicts choose what the copy draws. An unsigned document has nothing to + /// validate and keeps both. Viewer caches are invalidated by the UI trust path. + pub(crate) fn revalidate(doc: &mut Document, trust: Arc) { + doc.trust = trust; + if doc.signatures.is_empty() { + return; + } + let Some(editor) = doc.editor.as_mut() else { return }; + let mut signatures = signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache); + doc.display = display_bytes(Some(editor), &doc.bytes, &mut signatures, &doc.captions); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + } + + /// The words the caption of a named-layer signature appearance is drawn with + /// ([`pdfcraft_sign::show_status_layers`]), in the interface language. When they change, every + /// open document's display copy and renderer are rebuilt; like trust, this is not an edit. + /// Returns whether they changed. + pub fn set_signature_captions(&mut self, captions: StatusCaptions) -> bool { + if *self.captions == captions { + return false; + } + self.captions = Arc::new(captions); + for doc in &mut self.docs { + Self::draw_captions(doc, self.captions.clone()); + } + true + } + + /// Draw `doc`'s signature status layers with `captions`: the display copy and renderer are + /// rebuilt when it draws a signature's state (working file, generation and dirty unchanged). + /// Whether a state is drawn does not depend on the words: a caption that can't be drawn in + /// them is drawn in English. + pub(crate) fn draw_captions(doc: &mut Document, captions: Arc) { + doc.captions = captions; + if !doc.signatures.iter().any(|s| s.verdict_shown) { + return; + } + let Some(editor) = doc.editor.as_mut() else { return }; + let signatures = Arc::make_mut(&mut doc.signatures); + doc.display = display_bytes(Some(editor), &doc.bytes, signatures, &doc.captions); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + } + /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). pub fn signing_date(&self) -> String { let offset = self.local_offset(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c109a0bc80..d230d098de 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use super::document::is_placeholder_title; +use super::document::{is_placeholder_title, write_display_copy}; use super::edit::{EditCtx, check_permission, run_edit}; use super::*; use pdfcraft_render::{Layer, LayerOp, OpenError, ReadingColors}; @@ -3116,6 +3116,26 @@ fn a_word_in_a_font_set_by_an_extgstate_is_redacted() { ); } +/// A word drawn as outlines reads through its /ActualText. A mark over part of it applies (the +/// outlines are clipped), and the name that would still read the whole word is gone from the page +/// and from the saved file. +#[test] +fn a_word_drawn_as_outlines_is_redacted_with_its_name() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page("", "/Span <> BDC 20 150 m 140 150 l 140 170 l 20 170 l h f EMC"); + let id = s.open("outlines.pdf", None, Arc::new(page), None).unwrap(); + assert_eq!(page_texts(&s, id), ["SECRET42"]); + let shape = + Shape::Redact { quads: vec![pdfcraft_annot::rect_quad([10.0, 140.0, 90.0, 180.0])], overlay: String::new(), look: Default::default() }; + let mark = + Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() }); + s.apply(id, mark).unwrap(); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + assert_eq!(page_texts(&s, id), [""]); + let saved = s.save_bytes(id).unwrap(); + assert!(!saved.windows(b"SECRET42".len()).any(|w| w == b"SECRET42"), "the name is not left in the file"); +} + #[test] fn redaction_marks_apply_for_good_and_undo() { let (mut s, id) = session_with(2); @@ -4855,6 +4875,17 @@ fn bytes_has(haystack: &[u8], needle: &[u8]) -> bool { /// ranges and embed a detached CMS over them. The signed bytes keep the /// prepared appearance, so they validate exactly like a product signer's file. fn sign_prepared(doc: &mut pdfcraft_cos::Document, id: &sign::pkcs12::DigitalId, date: &str) -> Vec { + sign_prepared_with(doc, id, date, |_| {}) +} + +/// [`sign_prepared`], with `value` adding to the signature dictionary first (a certification's +/// `/Reference`, say). +fn sign_prepared_with( + doc: &mut pdfcraft_cos::Document, + id: &sign::pkcs12::DigitalId, + date: &str, + value: impl FnOnce(&mut pdfcraft_cos::Dict), +) -> Vec { use pdfcraft_cos::{Dict, Object, PdfString}; let reserve = 8192 + id.certificate.raw.len() + id.chain.iter().map(|c| c.raw.len()).sum::(); let mut v = Dict::new(); @@ -4864,6 +4895,7 @@ fn sign_prepared(doc: &mut pdfcraft_cos::Document, id: &sign::pkcs12::DigitalId, v.set(b"ByteRange".to_vec(), Object::Array([0].iter().chain(LAYERED_BR_MARK.iter()).map(|n| Object::Int(*n)).collect())); v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; reserve], hex: true })); v.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + value(&mut v); let sig = doc.add(Object::Dict(v)); // The field by name, never by number: a full save (encryption, repairs) is // free to renumber objects, and a hardcoded reference would stamp the value @@ -5104,6 +5136,442 @@ fn real_layered_signatures_round_trip_behind_a_user_password() { assert_eq!(t.get(tid).unwrap().signatures[0].status, SignatureStatus::Valid); } +/// A one-page file whose unsigned signature field carries a named-layer appearance, in the +/// geometry of a real one scaled by 4 (every word and drawing synthetic): /N draws /FRM, which +/// draws n0–n4; n1 a yellow question mark and n4 "Signature Not Verified". Object numbers: +/// 1 catalog, 2 pages, 3 page (400×300), 4 field, 5 AcroForm, 6 /N, 7 /FRM, 8–12 n0–n4, +/// 13 Helvetica, 14 page content. `sign_prepared` adds the value. +fn named_layer_signable_fixture() -> Vec { + named_layer_signable_fixture_with("[0 0 100 100]") +} + +/// [`named_layer_signable_fixture`] with `n1_bbox` as its n1 layer's `/BBox`. +fn named_layer_signable_fixture_with(n1_bbox: &str) -> Vec { + fn form(bbox: &str, resources: &str, ops: &str) -> String { + format!("<< /Type /XObject /Subtype /Form /BBox {bbox} /Resources {resources} /Length {} >>\nstream\n{ops}\nendstream", ops.len()) + } + let helv = "<< /Font << /F1 13 0 R >> >>"; + let glyph = "1.08 0 0 1.08 46 6 cm"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] /Contents 14 0 R /Resources << /Font << /F1 13 0 R >> >> /Annots [4 0 R] >>" + .to_string(), + "<< /FT /Sig /T (Signature1) /Type /Annot /Subtype /Widget /F 132 /Rect [100 100 300 220] /P 3 0 R /AP << /N 6 0 R >> >>".to_string(), + "<< /Fields [4 0 R] /SigFlags 3 >>".to_string(), + form("[0 0 200 120]", "<< /XObject << /FRM 7 0 R >> >>", "q 1 0 0 1 0 0 cm /FRM Do Q"), + form( + "[0 0 200 120]", + "<< /XObject << /n0 8 0 R /n1 9 0 R /n2 10 0 R /n3 11 0 R /n4 12 0 R >> >>", + &format!("q /n0 Do Q q {glyph} /n1 Do Q q /n2 Do Q q {glyph} /n3 Do Q q /n4 Do Q"), + ), + form("[0 0 100 100]", "<< >>", "% DSBlank"), + form( + n1_bbox, + "<< >>", + "q 1 0.84 0.2 rg 0.55 0.4 0 RG 3 w 30 72 m 30 88 42 98 56 98 c 72 98 84 88 84 74 c 66 50 l 58 28 l 44 28 l 44 48 l h B 44 6 14 14 re B Q", + ), + form("[0 0 200 120]", helv, "BT /F1 13.6 Tf 1 0 0 1 8 62.4 Tm (Digitally signed by) Tj 1 0 0 1 8 48.8 Tm (Test Signer) Tj ET"), + form("[0 0 100 100]", "<< >>", "% DSBlank"), + form("[0 84 200 120]", helv, "BT /F1 18.8 Tf 1 0 0 1 8 93.2 Tm (Signature Not Verified) Tj ET"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + "<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 270 Td (Contract text) Tj ET\nendstream".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (k, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", k + 1).as_bytes()); + } + let start = 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{start}\n%%EOF", objs.len() + 1).as_bytes()); + out +} + +/// The status colours as the regenerated icons draw them. +const VALID_GREEN: [u8; 3] = [0x2D, 0x9D, 0x78]; +const UNKNOWN_AMBER: [u8; 3] = [0xE6, 0x86, 0x19]; +const INVALID_RED: [u8; 3] = [0xD7, 0x37, 0x3F]; + +/// How many pixels of `rgba` are within 40 of `rgb` on every channel. A regenerated icon's +/// stroke covers thousands at 1 px/pt; the fixture holds none of these colours otherwise. +fn pixels_near(rgba: &[u8], rgb: [u8; 3]) -> usize { + rgba.as_chunks::<4>().0.iter().filter(|p| p.iter().zip(rgb).all(|(a, b)| a.abs_diff(b) <= 40)).count() +} + +/// The displayed page at 1 px/pt. +fn displayed_pixels(s: &Session, id: DocId) -> Vec { + let doc = s.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.display.clone(), doc.config.clone()); + let r = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(r.error.is_none(), "{:?}", r.error); + r.rgba.to_vec() +} + +fn exported_text(s: &Session, id: DocId) -> String { + export::Exporter::new(s.get(id).unwrap()).text_of(&[0]).unwrap() +} + +/// The test identity and the certificate that makes it trusted. +fn test_identity() -> (sign::pkcs12::DigitalId, sign::x509::Certificate) { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let id = sign::pkcs12::open(&p12, "test").unwrap(); + let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); + (id, anchor) +} + +/// A named-layer signature through `Session`: the unknown state drawn on opening, the valid one +/// once trusted, the signed bytes saved unchanged and still valid on reopening. +fn check_named_layer_states(signed: Arc>, password: Option<&str>, anchor: &sign::x509::Certificate) { + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("named.pdf", None, signed.clone(), password).unwrap(); + let sig = &s.get(id).unwrap().signatures[0]; + assert_eq!(sig.status, SignatureStatus::Unknown, "untrusted at first"); + assert!(sig.verdict_shown, "the appearance shows the unknown state"); + let text = exported_text(&s, id); + assert!(text.contains("Validity unknown") && !text.contains("Not Verified"), "{text:?}"); + let amber = pixels_near(&displayed_pixels(&s, id), UNKNOWN_AMBER); + assert!(amber > 500, "{amber} amber pixels"); + s.set_trusted_certificates(vec![anchor.clone()]); + let doc = s.get(id).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(doc.signatures[0].verdict_shown); + assert!(!doc.dirty, "trust is not an edit"); + let exported = exported_text(&s, id); + assert!(exported.contains("Signature valid"), "{exported}"); + let page = displayed_pixels(&s, id); + assert!(pixels_near(&page, VALID_GREEN) > 500 && pixels_near(&page, UNKNOWN_AMBER) < 50, "the page redraws for the new state"); + // Saving writes the working file, which never held the drawn state. + let saved = s.save_bytes(id).unwrap(); + assert!(saved == signed, "an unchanged save is the signed file exactly"); + let mut t = Session::new().with_clock(|| 1_800_000_000); + t.set_trusted_certificates(vec![anchor.clone()]); + let tid = t.open("named-saved.pdf", None, saved, password).unwrap(); + let sig = &t.get(tid).unwrap().signatures[0]; + assert!(sig.status == SignatureStatus::Valid && sig.verdict_shown); + let exported = exported_text(&t, tid); + assert!(exported.contains("Signature valid"), "{exported}"); +} + +#[test] +fn named_layer_signatures_show_their_state_through_trust_save_and_reopen() { + let (id, anchor) = test_identity(); + let mut doc = pdfcraft_cos::Document::open(Arc::new(named_layer_signable_fixture())).unwrap(); + let signed = Arc::new(sign_prepared(&mut doc, &id, "D:20261002120000+01'00'")); + check_named_layer_states(signed, None, &anchor); +} + +#[test] +fn named_layer_signatures_round_trip_behind_a_user_password() { + let (id, anchor) = test_identity(); + let mut doc = pdfcraft_cos::Document::open(Arc::new(named_layer_signable_fixture())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes128, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let encrypted = Arc::new(pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap()); + let mut doc = pdfcraft_cos::Document::open_with_password(encrypted, Some("user")).unwrap(); + let signed = Arc::new(sign_prepared(&mut doc, &id, "D:20261002120000+01'00'")); + check_named_layer_states(signed, Some("user"), &anchor); +} + +#[test] +fn a_tampered_named_layer_signature_shows_the_invalid_state() { + let (id, _) = test_identity(); + let mut doc = pdfcraft_cos::Document::open(Arc::new(named_layer_signable_fixture())).unwrap(); + let mut signed = sign_prepared(&mut doc, &id, "D:20261002120000+01'00'"); + let at = signed.windows(15).position(|w| w == b"(Contract text)").unwrap(); + signed[at + 10] = b'X'; + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("tampered.pdf", None, Arc::new(signed), None).unwrap(); + let sig = &s.get(id).unwrap().signatures[0]; + assert!(sig.status == SignatureStatus::Invalid && sig.verdict_shown); + let exported = exported_text(&s, id); + assert!(exported.contains("Signature invalid"), "{exported}"); + let red = pixels_near(&displayed_pixels(&s, id), INVALID_RED); + assert!(red > 500, "{red} red pixels"); +} + +/// A trusted named-layer signature. +fn trusted_named_layer_session() -> (Session, DocId) { + let (id, anchor) = test_identity(); + let mut doc = pdfcraft_cos::Document::open(Arc::new(named_layer_signable_fixture())).unwrap(); + let signed = Arc::new(sign_prepared(&mut doc, &id, "D:20261002120000+01'00'")); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let vid = s.open("named.pdf", None, signed, None).unwrap(); + s.set_trusted_certificates(vec![anchor]); + assert_eq!(s.get(vid).unwrap().signatures[0].status, SignatureStatus::Valid); + (s, vid) +} + +#[test] +fn named_layer_states_reach_exports_and_both_print_modes() { + // What is drawn on the page is what leaves it. + let (s, id) = trusted_named_layer_session(); + let doc = s.get(id).unwrap(); + let source = doc.export_source(); + let mut file = export::Exporter::from_source(export::ExportSource { bytes: doc.bytes.clone(), ..source.clone() }); + let mut shown = export::Exporter::from_source(source); + assert_ne!(shown.png(0, 72.0).unwrap(), file.png(0, 72.0).unwrap(), "image export follows the display copy"); + let text = shown.text_of(&[0]).unwrap(); + assert!(text.contains("Signature valid") && !text.contains("Not Verified"), "{text:?}"); + let mut settings = print::Settings { pages: vec![0], paper: (400.0, 300.0), ..Default::default() }; + let (_, _, vector) = printed_pixels(s.print_pdf(id, &settings).unwrap()); + settings.raster = Some(print::Raster { dpi: 72.0, grayscale: false }); + let (_, _, raster) = printed_pixels(s.print_pdf(id, &settings).unwrap()); + for (mode, pixels) in [("vector", &vector), ("raster", &raster)] { + assert!(pixels_near(pixels, VALID_GREEN) > 500, "{mode} print draws the valid state"); + assert!(pixels_near(pixels, [255, 214, 51]) < 50, "{mode} print drops the stored question mark"); + } +} + +#[test] +fn named_layers_show_beside_comments_that_need_appearances() { + // A comment without an appearance makes the display copy for its own sake; the signature's + // state is drawn into that same copy. + use pdfcraft_cos::{ObjRef, Object}; + let (signer, anchor) = test_identity(); + let mut cos = pdfcraft_cos::Document::open(Arc::new(named_layer_signable_fixture())).unwrap(); + let square = cos.add(pdfcraft_cos::Lexer::new(b"<< /Type /Annot /Subtype /Square /Rect [10 10 60 60] /C [1 0 0] >>", 0).object().unwrap()); + cos.update_dict(ObjRef::new(3, 0), |page| page.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(square)]))) + .unwrap(); + let signed = Arc::new(sign_prepared(&mut cos, &signer, "D:20261002120000+01'00'")); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("commented.pdf", None, signed, None).unwrap(); + s.set_trusted_certificates(vec![anchor]); + let shown = pdfcraft_cos::Document::open(s.get(id).unwrap().display.clone()).unwrap(); + assert!(shown.get(square).as_dict().is_some_and(|d| d.contains(b"AP")), "the comment's appearance is in the same copy"); + assert!(s.get(id).unwrap().signatures[0].verdict_shown); + let green = pixels_near(&displayed_pixels(&s, id), VALID_GREEN); + assert!(green > 500, "{green} green pixels"); +} + +#[test] +fn named_layers_show_beside_fields_the_viewer_draws() { + // A text field without an appearance makes the display copy for its own sake; the + // signature's state is drawn into that same copy. + use pdfcraft_cos::{Lexer, ObjRef, Object}; + let (signer, anchor) = test_identity(); + let mut cos = pdfcraft_cos::Document::open(Arc::new(named_layer_signable_fixture())).unwrap(); + let field = cos.add( + Lexer::new( + b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Test Signer) /DA (/F1 12 Tf 0 g) /Rect [20 20 380 40] /F 4 /P 3 0 R >>", + 0, + ) + .object() + .unwrap(), + ); + let both = Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(field)]); + cos.update_dict(ObjRef::new(3, 0), |page| page.set(b"Annots".to_vec(), both.clone())).unwrap(); + let resources = Lexer::new(b"<< /Font << /F1 13 0 R >> >>", 0).object().unwrap(); + cos.update_dict(ObjRef::new(5, 0), |form| { + form.set(b"Fields".to_vec(), both); + form.set(b"DR".to_vec(), resources); + }) + .unwrap(); + let signed = Arc::new(sign_prepared(&mut cos, &signer, "D:20261002120000+01'00'")); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("filled.pdf", None, signed, None).unwrap(); + s.set_trusted_certificates(vec![anchor]); + let shown = pdfcraft_cos::Document::open(s.get(id).unwrap().display.clone()).unwrap(); + assert!(shown.get(field).as_dict().is_some_and(|d| d.contains(b"AP")), "the field's appearance is in the same copy"); + assert!(s.get(id).unwrap().signatures[0].verdict_shown); + let green = pixels_near(&displayed_pixels(&s, id), VALID_GREEN); + assert!(green > 500, "{green} green pixels"); +} + +#[test] +fn a_metadata_edit_keeps_the_regenerated_state() { + let (mut s, id) = trusted_named_layer_session(); + s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Contract".into() }).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.signatures[0].verdict_shown, "the page still draws the state"); + let exported = exported_text(&s, id); + assert!(exported.contains("Signature valid"), "{exported}"); +} + +#[test] +fn a_metadata_edit_that_changes_a_verdict_redraws_it() { + // A certification that allows no changes: setting the title invalidates the signature, and + // the page has to say so rather than keep the valid state it drew. + let (signer, anchor) = test_identity(); + let mut cos = pdfcraft_cos::Document::open(Arc::new(named_layer_signable_fixture())).unwrap(); + let signed = sign_prepared_with(&mut cos, &signer, "D:20261002120000+01'00'", |v| { + let reference = + pdfcraft_cos::Lexer::new(b"[<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 1 /V /1.2 >> >>]", 0) + .object() + .unwrap(); + v.set(b"Reference".to_vec(), reference); + }); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("certified.pdf", None, Arc::new(signed), None).unwrap(); + s.set_trusted_certificates(vec![anchor]); + assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid); + s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Changed".into() }).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Invalid); + assert!(doc.signatures[0].verdict_shown); + let exported = exported_text(&s, id); + assert!(exported.contains("Signature invalid"), "{exported}"); + let red = pixels_near(&displayed_pixels(&s, id), INVALID_RED); + assert!(red > 500, "{red} red pixels"); +} + +#[test] +fn verdict_marks_carry_over_only_while_every_verdict_is_unchanged() { + let (s, id) = trusted_named_layer_session(); + let previous = s.get(id).unwrap().signatures.to_vec(); + assert!(previous[0].verdict_shown); + let mut same = previous.clone(); + same[0].verdict_shown = false; + assert!(carry_verdict_marks(&previous, &mut same) && same[0].verdict_shown); + let mut changed = previous.clone(); + (changed[0].verdict_shown, changed[0].status) = (false, SignatureStatus::Invalid); + assert!(!carry_verdict_marks(&previous, &mut changed) && !changed[0].verdict_shown); + let mut later = previous.clone(); + (later[0].verdict_shown, later[0].revision) = (false, previous[0].revision + 1); + assert!(!carry_verdict_marks(&previous, &mut later) && !later[0].verdict_shown); +} + +#[test] +fn releasing_the_working_file_drops_the_marks() { + // The page shows the original's own artwork until the refresh finishes. + let (mut s, id) = trusted_named_layer_session(); + let doc = s.docs.iter_mut().find(|d| d.id == id).unwrap(); + assert!(doc.signatures[0].verdict_shown); + Session::release_working(doc); + assert!(!doc.signatures[0].verdict_shown); +} + +#[test] +fn background_open_uses_the_current_trust_at_commit() { + let (signer, anchor) = test_identity(); + let mut cos = pdfcraft_cos::Document::open(Arc::new(named_layer_signable_fixture())).unwrap(); + let signed = Arc::new(sign_prepared(&mut cos, &signer, "D:20261002120000+01'00'")); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let prepared = s.open_context().prepare_open("background.pdf", None, signed, None).unwrap(); + s.set_trusted_certificates(vec![anchor]); + let id = s.commit_open(prepared); + let sig = &s.get(id).unwrap().signatures[0]; + assert!(sig.status == SignatureStatus::Valid && sig.verdict_shown); + let exported = exported_text(&s, id); + assert!(exported.contains("Signature valid"), "{exported}"); +} + +#[test] +fn background_open_uses_the_current_trust_and_language_at_commit() { + // Both changed while the document was prepared: the state and its words are both the new ones. + let (signer, anchor) = test_identity(); + let mut cos = pdfcraft_cos::Document::open(Arc::new(named_layer_signable_fixture())).unwrap(); + let signed = Arc::new(sign_prepared(&mut cos, &signer, "D:20261002120000+01'00'")); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let prepared = s.open_context().prepare_open("background.pdf", None, signed, None).unwrap(); + s.set_trusted_certificates(vec![anchor]); + s.set_signature_captions(StatusCaptions { + valid: "Signatur gültig".into(), + unknown: "Gültigkeit unbekannt".into(), + invalid: "Signatur ungültig".into(), + }); + let id = s.commit_open(prepared); + assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid); + let exported = exported_text(&s, id); + assert!(exported.contains("Signatur gültig"), "{exported}"); +} + +#[test] +fn printing_draws_signature_states_only_where_the_screen_does() { + let (mut s, id) = trusted_named_layer_session(); + let settings = print::Settings { pages: vec![0], paper: (400.0, 300.0), ..Default::default() }; + // The screen falls back to the file's artwork when its display copy cannot be written, and + // takes the marks with it; print follows. + let doc = s.docs.iter_mut().find(|d| d.id == id).unwrap(); + let mut cleared = doc.signatures.to_vec(); + cleared[0].verdict_shown = false; + doc.signatures = Arc::new(cleared); + let (_, _, printed) = printed_pixels(s.print_pdf(id, &settings).unwrap()); + let green = pixels_near(&printed, VALID_GREEN); + assert!(green < 50, "{green} green pixels"); +} + +#[test] +fn a_failed_display_write_drops_the_marks() { + let (mut s, id) = trusted_named_layer_session(); + let doc = s.docs.iter_mut().find(|d| d.id == id).unwrap(); + let mut signatures = doc.signatures.to_vec(); + assert!(signatures[0].verdict_shown); + // A stream-limited document is read-only: the writer refuses it. + let unwritable = pdfcraft_cos::Document::open_with_stream_limit(doc.bytes.clone(), None, 1).unwrap(); + let editor = doc.editor.as_mut().unwrap(); + let shown = write_display_copy(editor, &doc.bytes, &unwritable, &mut signatures); + assert!(Arc::ptr_eq(&shown, &doc.bytes), "the page falls back to the file's own artwork"); + assert!(!signatures[0].verdict_shown, "so nothing counts as drawn"); + // Logged once, however many refreshes lose the copy again. + write_display_copy(editor, &doc.bytes, &unwritable, &mut signatures); + let logged = editor.cos.repair_log().iter().filter(|l| l.contains("the display copy could not be written")).count(); + assert_eq!(logged, 1); +} + +#[test] +fn background_open_uses_the_current_caption_language_at_commit() { + let (mut s, id) = trusted_named_layer_session(); + let bytes = s.get(id).unwrap().bytes.clone(); + let prepared = s.open_context().prepare_open("background.pdf", None, bytes, None).unwrap(); + s.set_signature_captions(StatusCaptions { + valid: "Signatur gültig".into(), + unknown: "Gültigkeit unbekannt".into(), + invalid: "Signatur ungültig".into(), + }); + let later = s.commit_open(prepared); + let exported = exported_text(&s, later); + assert!(exported.contains("Signatur gültig"), "{exported}"); + assert!(s.get(later).unwrap().signatures[0].verdict_shown); + assert!(!s.get(later).unwrap().dirty); +} + +#[test] +fn caption_language_follows_the_session_captions() { + let (mut s, id) = trusted_named_layer_session(); + let generation = s.get(id).unwrap().generation; + let german = StatusCaptions { valid: "Signatur gültig".into(), unknown: "Gültigkeit unbekannt".into(), invalid: "Signatur ungültig".into() }; + s.set_signature_captions(german); + let doc = s.get(id).unwrap(); + assert!(!doc.dirty && doc.generation == generation, "captions are not an edit"); + assert!(doc.signatures[0].verdict_shown); + let exported = exported_text(&s, id); + assert!(exported.contains("Signatur gültig"), "{exported}"); + // Documents opened later draw with them too. + let (signer, _) = test_identity(); + let mut cos = pdfcraft_cos::Document::open(Arc::new(named_layer_signable_fixture())).unwrap(); + let later = s.open("later.pdf", None, Arc::new(sign_prepared(&mut cos, &signer, "D:20261002120000+01'00'")), None).unwrap(); + let exported = exported_text(&s, later); + assert!(exported.contains("Signatur gültig"), "{exported}"); +} + +#[test] +fn non_win_ansi_captions_embed_inter_in_the_display_copy_only() { + // Words WinAnsi cannot encode are drawn in Inter, embedded into the display copy only. + let (mut s, id) = trusted_named_layer_session(); + let russian = StatusCaptions { + valid: "Подпись действительна".into(), + unknown: "Действительность неизвестна".into(), + invalid: "Подпись недействительна".into(), + }; + s.set_signature_captions(russian); + let exported = exported_text(&s, id); + assert!(exported.contains("Подпись действительна"), "{exported}"); + let doc = s.get(id).unwrap(); + assert!(bytes_has(&doc.display, b"/BaseFont /Inter") && !bytes_has(&doc.bytes, b"/BaseFont /Inter"), "Inter goes into the display copy only"); +} + #[test] #[ignore = "timing probe"] fn probe_edit_latency_on_a_large_signed_document() { @@ -7654,6 +8122,37 @@ fn free_text_without_appearance() -> Arc> { Arc::new(bytes) } +/// The CLI renders what the app shows with no trusted certificates: a named-layer signature in +/// the state it has then (validity unknown), not the "not verified" its file stores. The source +/// is not changed. +#[test] +fn cli_display_draws_signature_states_as_the_app_does_without_trust() { + let (id, _) = test_identity(); + let mut doc = pdfcraft_cos::Document::open(Arc::new(named_layer_signable_fixture())).unwrap(); + let signed = Arc::new(sign_prepared(&mut doc, &id, "D:20261002120000+01'00'")); + let display = display_bytes_for(signed.clone(), None); + assert!(!Arc::ptr_eq(&display, &signed), "the state is drawn into a copy"); + let mut renderer = pdfcraft_render::PageRenderer::new(display, pdfcraft_render::RenderConfig::default()); + let page = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(pixels_near(&page.rgba, UNKNOWN_AMBER) > 500, "the unknown state is drawn"); + assert!(pixels_near(&page.rgba, [255, 214, 51]) < 50, "the stored question mark is not"); +} + +/// A layer recognised but too small to draw an icon in (a side so small the icon's scale rounds to +/// zero) changes nothing, so the CLI renders the source, as the app does. +#[test] +fn cli_display_makes_no_copy_when_no_state_could_be_drawn() { + let (id, _) = test_identity(); + let n1 = format!("[0 0 0.{}6 100]", "0".repeat(322)); + let mut doc = pdfcraft_cos::Document::open(Arc::new(named_layer_signable_fixture_with(&n1))).unwrap(); + let signed = Arc::new(sign_prepared(&mut doc, &id, "D:20261002120000+01'00'")); + let cos = pdfcraft_cos::Document::open(signed.clone()).unwrap(); + let trust = pdfcraft_sign::TrustStore::default(); + let signatures = crate::doc_state::signatures_of(&cos, &signed, &trust, &pdfcraft_sign::DigestCache::default()); + assert!(pdfcraft_sign::has_status_layers(&cos, &signatures), "the layers are recognised"); + assert!(Arc::ptr_eq(&display_bytes_for(signed.clone(), None), &signed), "nothing was drawn, so there is no copy"); +} + /// #2371: a comment without an appearance stream is displayed by the app, so the CLI's page image /// must show it too. The helper's copy is for rendering only; documents that need no copy, and /// files it can't display, keep exactly the original source bytes. @@ -8514,6 +9013,141 @@ fn a_signed_document_rebuilt_from_object_headers_refuses_edits() { assert!(!s.get(id).unwrap().dirty); } +/// A one-page document whose only signature field is signed and named with `len` bytes. The +/// signature field walk reads names of up to 4096 bytes and passes longer ones over. +fn signed_field_named(len: usize) -> Vec { + pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R] >>".into(), + format!("<< /Type /Annot /Subtype /Widget /FT /Sig /T ({}) /V 5 0 R /Rect [20 20 180 60] /P 3 0 R >>", "a".repeat(len)), + "<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /ByteRange [0 1 2 1] /Contents <00> >>".into(), + ]) +} + +/// A PDF of `objs`, numbered from 1, with a cross-reference table. +fn pdf_of(objs: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn open_signed_field_named(len: usize) -> (Session, DocId) { + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(signed_field_named(len)), None).expect("opens"); + (s, id) +} + +/// The cap's boundary as the engine meets it: a 4096-byte name is read, and its signature blocks a +/// full rewrite. +#[test] +fn a_signature_named_at_the_cap_is_listed_and_blocks_a_full_rewrite() { + let (s, id) = open_signed_field_named(4096); + let doc = s.get(id).unwrap(); + let listed: Vec<_> = doc.signatures.iter().map(|r| (r.field.len(), r.signed)).collect(); + assert_eq!(listed, [(4096, true)], "the field is read, and no row stands for unread ones"); + assert_eq!(s.save_full_bytes(id).unwrap_err(), EditError::Signed); +} + +/// One byte past the cap the field goes unread: the list says so, and a full rewrite is still +/// refused. +#[test] +fn a_signature_named_one_byte_past_the_cap_still_blocks_a_full_rewrite() { + let (s, id) = open_signed_field_named(4097); + let doc = s.get(id).unwrap(); + let unread = doc.signatures.iter().find(|r| r.field == "UnreadFields"); + let said = unread.map(|r| (r.signed, r.status, r.details.first().is_some_and(|d| d.message.starts_with("This form has too many fields")))); + assert_eq!(said, Some((true, SignatureStatus::Unknown, true)), "{:?}", doc.signatures); + assert_eq!(s.save_full_bytes(id).unwrap_err(), EditError::Signed); +} + +/// A signed field behind a kid with no /T: one merged with its widget under a named field that +/// isn't a signature field (the walk can't list it, so its row is the unread one), and one under a +/// node with no /T (listed by its full name). Either way the document is signed, and neither a full +/// save nor Optimize may rewrite it. +#[test] +fn signed_fields_behind_kids_without_a_name_block_a_full_rewrite() { + let page = |annot: u32| format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [{annot} 0 R] >>"); + let head = |annot: u32| { + vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + page(annot), + ] + }; + let sig = "<< /Type /Sig /SubFilter /ETSI.CAdES.detached /ByteRange [0 1 2 1] /Contents <00> >>".to_string(); + let mut merged = head(5); + merged.extend([ + "<< /T (parent) /Kids [5 0 R] >>".to_string(), + "<< /Type /Annot /Subtype /Widget /FT /Sig /V 6 0 R /Parent 4 0 R /Rect [20 20 180 60] /P 3 0 R >>".into(), + sig.clone(), + ]); + let mut nested = head(6); + nested.extend([ + "<< /T (p) /Kids [5 0 R] >>".to_string(), + "<< /Kids [6 0 R] /Parent 4 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Sig /T (s) /V 7 0 R /Parent 5 0 R /Rect [20 20 180 60] /P 3 0 R >>".into(), + sig, + ]); + for (shape, objs, row) in [("merged", merged, "UnreadFields"), ("nested", nested, "p.s")] { + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf_of(&objs)), None).expect("opens"); + let doc = s.get(id).unwrap(); + assert!(doc.is_signed() && doc.signatures.iter().any(|r| r.field == row), "{shape}: {:?}", doc.signatures); + assert_eq!(s.save_full_bytes(id).unwrap_err(), EditError::Signed, "{shape}"); + assert!(matches!(s.optimize_job(id, &optimize::Settings::default(), &[]), Err(EditError::Signed)), "{shape}"); + } +} + +/// The walk stops short of a signed field, so a signature may go unlisted; the document still +/// counts as signed, and each guard against rewriting a signed document holds. +#[test] +fn a_signature_the_field_walk_passes_over_still_counts_as_signed() { + let (s, id) = open_signed_field_named(64 << 10); + let doc = s.get(id).unwrap(); + assert!(doc.is_signed(), "{:?}", doc.signatures); +} + +#[test] +fn a_full_rewrite_is_refused_when_the_field_walk_passes_a_signature_over() { + let (s, id) = open_signed_field_named(64 << 10); + assert_eq!(s.save_full_bytes(id).unwrap_err(), EditError::Signed); +} + +#[test] +fn optimizing_is_refused_when_the_field_walk_passes_a_signature_over() { + let (s, id) = open_signed_field_named(64 << 10); + assert!(matches!(s.optimize_job(id, &optimize::Settings::default(), &[]), Err(EditError::Signed))); +} + +#[test] +fn a_rewriting_edit_is_refused_when_the_field_walk_passes_a_signature_over() { + let (mut s, id) = open_signed_field_named(64 << 10); + assert!(matches!(s.apply(id, Edit::Sanitize), Err(EditError::SignedRewrite(_)))); +} + +#[test] +fn an_action_appends_to_a_file_whose_field_walk_passes_a_signature_over() { + let bytes = Arc::new(signed_field_named(64 << 10)); + let steps = vec![actions::Step::from_id("set_title", "Checked").unwrap()]; + let title = actions::Action { name: "Title".into(), description: String::new(), steps, builtin: false }; + let r = actions::run_on(&title, "form.pdf", bytes.clone(), |_, _| {}).unwrap(); + assert!(r.bytes.starts_with(&bytes[..]), "the action rewrote the file instead of appending to it"); + let mut s = Session::new(); + let id = s.open("out.pdf", None, r.bytes, None).unwrap(); + assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Checked"), "the title is in what was appended"); +} + /// #1324: marking a figure decorative rewrites that figure only. The header, added text and an /// unknown entry on the original stream stay attached to the page. This lives in the engine tests /// because edit and a11y are the same layer and cannot depend on each other. diff --git a/crates/engine/src/word_count.rs b/crates/engine/src/word_count.rs index feeb0a066f..c20dc8325f 100644 --- a/crates/engine/src/word_count.rs +++ b/crates/engine/src/word_count.rs @@ -78,6 +78,7 @@ fn count_inner(source: ExportSource, pages: &[usize], options: Options, mut prog tables: options.exclude_tables, code: options.exclude_code, images: options.exclude_images, + painted: false, mcids: excluded.remove(&page).unwrap_or_default(), }; let text = renderer.filtered_text(page, filter).map_err(|e| format!("page {}: {e}", page.saturating_add(1)))?.plain_text(); diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 45cb843ab0..4ebe181a75 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -31,7 +31,7 @@ pub use script::{ script_missing, script_outline, }; pub use shaping::{ - ShapedCluster, ShapedScript, arabic_glyph, arabic_has, hebrew_has, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, thai_has, + ShapedCluster, ShapedScript, arabic_glyph, arabic_has, face_glyph, hebrew_has, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, thai_has, }; pub use std14::Std14; diff --git a/crates/fonts/src/shaping.rs b/crates/fonts/src/shaping.rs index f0bd118c15..54a401875f 100644 --- a/crates/fonts/src/shaping.rs +++ b/crates/fonts/src/shaping.rs @@ -147,6 +147,16 @@ pub fn arabic_glyph(id: u32) -> Result { /// A bounded outline from the same face that produced the shaped glyph id. pub fn shaped_glyph(script: ShapedScript, id: u32) -> Result { let (font, _) = face(script).ok_or(GlyphError::NoFont)?; + glyph(font, id) +} + +/// Glyph `id` of the face in `bytes`, bounded like [`shaped_glyph`]: for text shaped with +/// [`crate::source_shaping::shape`] in a face no [`ShapedScript`] names. +pub fn face_glyph(bytes: &[u8], id: u32) -> Result { + glyph(&FontRef::new(bytes).map_err(|_| GlyphError::NoFont)?, id) +} + +fn glyph(font: &FontRef, id: u32) -> Result { if font.maxp().map_or(true, |m| id >= u32::from(m.num_glyphs())) { return Err(GlyphError::Missing); } diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index b0a7257cb3..bd42efce39 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -361,7 +361,7 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], processed.push((done.ops, done.changed)); } if scope.mode == Mode::Apply { - clean_replacements(doc, resources, &original, &mut processed, scope.report); + clean_replacements(doc, resources, &original, &walk.clipped, &mut processed, scope.report); } walk.out.streams = processed.into_iter().zip(comment_edges).map(|((ops, changed), edges)| changed.then(|| piece_bytes(&ops, edges))).collect(); walk.out @@ -424,6 +424,9 @@ struct Walk { path: Vec, path_box: Option<[f64; 4]>, clip: bool, + /// Painting operators written again under a clip that cuts the regions out: unchanged as + /// operators, but what they paint is no longer all there. + clipped: HashSet, out: Output, } @@ -442,6 +445,7 @@ impl Walk { path: Vec::new(), path_box: None, clip: false, + clipped: HashSet::new(), out: Output::default(), } } @@ -474,7 +478,7 @@ impl Walk { } b"Tj" | b"TJ" | b"'" | b"\"" => self.show_text(scope, op, piece), b"BI" => Self::inline_image(&self.gs, scope, &mut self.out, op, piece), - b"sh" if scope.mode == Mode::Apply => Self::shading(&self.gs, scope, op, piece), + b"sh" if scope.mode == Mode::Apply => Self::shading(&self.gs, scope, &mut self.clipped, op, piece), b"Do" => self.draw_xobject(doc, scope, resources, op, piece), // `/Name scn` selects a pattern as the fill colour; numbers select a plain colour // and clear it. `cs` with a space other than /Pattern clears it too. @@ -717,10 +721,12 @@ impl Walk { None } - /// A shading fills the current clip: the regions are clipped out. - fn shading(gs: &Gs, scope: &Scope<'_>, op: Op, piece: &mut Piece) -> Option { - let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) else { return Some(op) }; + /// A shading fills the current clip: the regions are clipped out. Removing hidden content has + /// no regions, so the shading and its name stay as they are. + fn shading(gs: &Gs, scope: &Scope<'_>, clipped: &mut HashSet, op: Op, piece: &mut Piece) -> Option { + let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]).filter(|c| !c.is_empty()) else { return Some(op) }; piece.changed = true; + clipped.insert(op.span.start); piece.ops.push(Op::new("q", vec![])); piece.ops.extend(c); piece.ops.push(op); @@ -739,7 +745,16 @@ impl Walk { } } -fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec, bool)], report: &mut Report) { +/// Strip `/ActualText` (and `/Alt`, `/E`, `/T`) from the marked content around anything redaction +/// removed, changed or `clipped`: the words they name may be among what is gone. +fn clean_replacements( + doc: &Document, + resources: &Dict, + original: &[Op], + clipped: &HashSet, + processed: &mut [(Vec, bool)], + report: &mut Report, +) { let originals: HashMap = original.iter().map(|op| (op.span.start, op)).collect(); let unchanged: HashSet = processed.iter().flat_map(|(ops, _)| ops).filter(|op| originals.get(&op.span.start) == Some(op)).map(|op| op.span.start).collect(); @@ -751,7 +766,13 @@ fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], process b"EMC" => { stack.pop(); } - _ if !unchanged.contains(&op.span.start) => affected.extend(stack.iter().copied()), + // A scope joins `affected` only with every scope around it, so once the innermost one + // is in, the rest are too: deep nesting over many changed ops stays linear. + _ if (!unchanged.contains(&op.span.start) || clipped.contains(&op.span.start)) + && stack.last().is_some_and(|top| !affected.contains(top)) => + { + affected.extend(stack.iter().copied()) + } _ => {} } } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 8978e0e7d6..70c9baacd0 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -61,6 +61,68 @@ fn reopen(doc: &Document) -> Document { Document::open(Arc::new(write_incremental(doc, &SaveOptions::default()).unwrap())).unwrap() } +/// A word drawn as outlines names itself in /ActualText. A mark over part of it removes the +/// outlines (a shading painting them is clipped instead), and the name, which would still read the +/// whole word, goes with them. A word the mark misses keeps its own. +#[test] +fn redacted_paint_loses_the_actual_text_that_names_it() { + let mut doc = one_page( + b"/Span <> BDC 100 100 m 160 100 l 160 120 l 100 120 l h f EMC /Span <> BDC 100 200 60 20 re f EMC", + "", + vec![], + ); + mark(&mut doc, 0, &[[90.0, 95.0, 130.0, 125.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.paths_clipped, r.paths_removed), (0, 1)); + let c = content(&doc, 0); + assert!(!c.contains("SECRET") && c.contains("KEPT"), "{c}"); + + let shading = + "<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 300 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >>"; + let mut doc = one_page(b"/Span <> BDC /Sh0 sh EMC", "/Shading << /Sh0 7 0 R >>", vec![shading.as_bytes().to_vec()]); + mark(&mut doc, 0, &[[90.0, 95.0, 130.0, 125.0]], ""); + apply(&mut doc, None).unwrap(); + let c = content(&doc, 0); + // Drawn after a clip that cuts the region out. + assert!(!c.contains("SHADED") && c.find("W*").zip(c.find(" sh")).is_some_and(|(w, s)| w < s), "{c}"); + + // A path that clips as it paints. + let mut doc = one_page(b"q /Span <> BDC 100 100 m 160 100 l 160 120 l 100 120 l h W f EMC Q", "", vec![]); + mark(&mut doc, 0, &[[90.0, 95.0, 130.0, 125.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 1); + let c = content(&doc, 0); + assert!(!c.contains("CLIPS"), "{c}"); +} + +/// Nested names around redacted paint all go, the innermost too, whichever scope was cut first. +#[test] +fn redacted_paint_loses_every_name_around_it() { + let mut doc = one_page( + b"/Span <> BDC 100 100 m 160 100 l 160 120 l 100 120 l h f /Span <> BDC 100 130 m 160 130 l 160 150 l 100 150 l h f EMC EMC", + "", + vec![], + ); + mark(&mut doc, 0, &[[90.0, 95.0, 130.0, 155.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 2); + let c = content(&doc, 0); + assert!(!c.contains("OUTER") && !c.contains("INNER"), "{c}"); +} + +/// Removing hidden text clips nothing, so a shading on the same page keeps the words that name it. +#[test] +fn removing_hidden_text_keeps_the_alt_text_of_a_shading() { + let shading = + "<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 300 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >>"; + let mut doc = one_page( + b"BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET /Figure <> BDC /Sh0 sh EMC", + "/Shading << /Sh0 7 0 R >>", + vec![shading.as_bytes().to_vec()], + ); + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + let c = content(&doc, 0); + assert!(!c.contains("SECRET") && c.contains("(chart)"), "{c}"); +} + #[test] fn glyphs_under_a_mark_go_and_the_rest_stays_put() { // "AB1234CD" from x = 10 at 10 pt: each glyph 5 pt wide; 1234 spans x 20–40. diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 12a1e3aecd..aa66a4b683 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -23,6 +23,9 @@ pub struct TextFilter { pub code: bool, /// Exclude Figure-marked text and invisible text layers, including OCR. pub images: bool, + /// Exclude `/ActualText` that marks no glyph, whose words are drawn as outlines or pictures: + /// a page's own text, for deciding whether it still needs reading from its pixels. + pub painted: bool, /// Page-local MCIDs whose structure ancestors match the selected exclusions. pub mcids: HashSet, } diff --git a/crates/render/src/text/device.rs b/crates/render/src/text/device.rs index 7c4881e8be..6c14325400 100644 --- a/crates/render/src/text/device.rs +++ b/crates/render/src/text/device.rs @@ -25,6 +25,16 @@ pub(super) struct TextDevice { /// The outermost open `/ActualText` replacement: its depth, text and the glyphs drawn inside /// it (with no text of their own yet). actual: Option<(usize, String, Vec)>, + /// What the open `/ActualText` replacement paints besides glyphs (paths, images), as far as + /// it can be seen: where its text reads when it marks no glyph (words drawn as outlines). + painted: Option, + /// Whether the open `/ActualText` replacement paints anything an exclusion leaves out. + paint_filtered: bool, + /// Whether the open `/ActualText` replacement paints anything wholly outside the crop box. + paint_cropped: bool, + /// The bounding boxes of the clip paths in force (view space), innermost last, each cut to the + /// one before it: an outer bound on what can show. + clips: Vec, /// Every `/ActualText` replacement made on the page. pub(super) replaced: Vec, /// Visible page bounds in view space (`intersected_crop_box` after `initial_transform`). @@ -105,12 +115,109 @@ fn merge(drawn: &[Drawn], text: String) -> Option { Some((quarter, u, TextGlyph { text, rect, quad, direction: first.2.direction })) } +/// The widest or tallest box (view-space points) a glyph, or the paint standing in for one, may +/// have and still be placed: anything bigger is damage, not text. +const MAX_GLYPH_EXTENT: f64 = 10_000.0; + +/// The quarter turn of direction `(dx, dy)` (view space, y down): 0 right, 1 down, 2 left, 3 up. +fn quarter_turn(dx: f64, dy: f64) -> usize { + if dx.abs() >= dy.abs() { + if dx >= 0.0 { 0 } else { 2 } + } else if dy > 0.0 { + 1 + } else { + 3 + } +} + +/// What an `/ActualText` replacement paints besides glyphs, measured as [`merge`] measures +/// glyphs: along the writing direction of the first of it (its transform's x axis, `u`) and +/// across it. +struct Painted { + quarter: usize, + u: [f64; 2], + along: [f64; 2], + across: [f64; 2], +} + +impl Painted { + fn new(transform: Affine) -> Self { + let [a, b, ..] = transform.as_coeffs(); + let length = a.hypot(b); + let u = if length.is_finite() && length > 0.0 { [a / length, b / length] } else { [1.0, 0.0] }; + let empty = [f64::INFINITY, f64::NEG_INFINITY]; + Painted { quarter: quarter_turn(u[0], u[1]), u, along: empty, across: empty } + } + + /// Grow the measure by the view-space box `b`. + fn add(&mut self, b: Rect) { + let n = [-self.u[1], self.u[0]]; + for (x, y) in [(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1)] { + let (s, t) = (x * self.u[0] + y * self.u[1], x * n[0] + y * n[1]); + self.along = [self.along[0].min(s), self.along[1].max(s)]; + self.across = [self.across[0].min(t), self.across[1].max(t)]; + } + } + + /// A stand-in glyph with no text of its own, boxed around what was painted and read along its + /// direction; `None` when that is too big to be the place of words. + fn glyph(self) -> Option { + let ([s0, s1], [t0, t1]) = (self.along, self.across); + // Too big to be the place of words, or NaN. + if !(s1 - s0 <= MAX_GLYPH_EXTENT && t1 - t0 <= MAX_GLYPH_EXTENT) { + return None; + } + let (u, n) = (self.u, [-self.u[1], self.u[0]]); + let point = |s: f64, t: f64| [(s * u[0] + t * n[0]) as f32, (s * u[1] + t * n[1]) as f32]; + // The edge towards the next line starts the quad, as a glyph's em box does. + let quad = [point(s0, t1), point(s1, t1), point(s1, t0), point(s0, t0)]; + let rect = quad.iter().fold([f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY], |r, p| { + [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])] + }); + rect.iter() + .all(|v| v.is_finite()) + .then(|| (self.quarter, u, TextGlyph { text: String::new(), rect, quad, direction: [u[0] as f32, u[1] as f32] })) + } +} + +/// The part of `b` inside `c`, or `None` when none of it is. A NaN edge of `c` (a damaged clip) +/// leaves that edge uncut. +fn cut(b: Rect, c: Rect) -> Option { + let (x0, y0, x1, y1) = (b.x0.max(c.x0), b.y0.max(c.y0), b.x1.min(c.x1), b.y1.min(c.y1)); + (x0 <= x1 && y0 <= y1).then(|| Rect::new(x0, y0, x1, y1)) +} + +/// The view-space box of `path` drawn with `transform`, from its control points (as redaction +/// measures a path), or `None` when it has no finite point. +fn control_box(path: &BezPath, transform: Affine) -> Option { + let mut b: Option = None; + let mut add = |p: kurbo::Point| { + let q = transform * p; + if q.is_finite() { + b = Some(b.map_or_else(|| Rect::from_points(q, q), |r| r.union_pt(q))); + } + }; + for el in path.elements() { + match *el { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => add(p), + kurbo::PathEl::QuadTo(p1, p2) => [p1, p2].into_iter().for_each(&mut add), + kurbo::PathEl::CurveTo(p1, p2, p3) => [p1, p2, p3].into_iter().for_each(&mut add), + kurbo::PathEl::ClosePath => {} + } + } + b +} + impl TextDevice { pub(super) fn new() -> Self { TextDevice { glyphs: Vec::new(), depth: 0, actual: None, + painted: None, + paint_filtered: false, + paint_cropped: false, + clips: Vec::new(), replaced: Vec::new(), page: Rect::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY), filter: TextFilter::default(), @@ -128,9 +235,14 @@ impl TextDevice { /// words can't be found. /// /// A replacement spanning several lines or writing directions stays per glyph (each glyph's own - /// Unicode). An empty one (a soft hyphen at a line break, say) stands for no text at all. + /// Unicode). An empty one (a soft hyphen at a line break, say) stands for no text at all. One + /// that marks no glyph reads where it paints, as far as that can be seen: words drawn as + /// outlines or pictures. fn end_actual_text(&mut self) { - let Some((_, text, drawn)) = self.actual.take() else { return }; + let Some((_, text, mut drawn)) = self.actual.take() else { return }; + let painted = self.painted.take(); + let paint_filtered = std::mem::take(&mut self.paint_filtered); + let paint_cropped = std::mem::take(&mut self.paint_cropped); let cropped = std::mem::take(&mut self.actual_cropped); if std::mem::take(&mut self.actual_filtered) { // ActualText has no mapping from replacement words to individual glyphs. A mixed @@ -138,10 +250,27 @@ impl TextDevice { self.partial_replacement |= !drawn.is_empty(); return; } + // Before the paint: a span whose glyphs the crop box took has none left, yet it marks + // glyphs, so its paint doesn't place it. if cropped { self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(|c| c.is_control()))); return; } + if drawn.is_empty() + && !self.filter.painted + && let Some(stand_in) = painted.and_then(Painted::glyph) + { + // The paint places it, so paint left out mixes it as excluded glyphs would. + if paint_filtered { + self.partial_replacement = true; + return; + } + // Its words may be among what the crop box hides, as with a glyph cropped away. + if paint_cropped { + return; + } + drawn.push(stand_in); + } let Some(glyph) = merge(&drawn, text.clone()) else { self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(char::is_control))); return; @@ -156,6 +285,25 @@ impl TextDevice { self.replaced.push(Replaced { at, text, originals }); } + /// Record view-space box `b`, drawn with `transform`, as painted by the open `/ActualText` + /// replacement, cut to the visible area (the crop box, and the clip in force); paint wholly + /// outside the crop box, or that an exclusion leaves out (`excluded`), only marks the + /// replacement. + fn paint(&mut self, b: Rect, transform: Affine, excluded: bool) { + // Wholly outside the crop box, as a glyph dropped there is: the replacement can't be read. + let Some(on_page) = cut(b, self.page) else { + self.paint_cropped = true; + return; + }; + // Clipped away entirely: nothing of it shows. + let Some(seen) = self.clips.last().map_or(Some(on_page), |c| cut(on_page, *c)) else { return }; + if excluded { + self.paint_filtered = true; + } else { + self.painted.get_or_insert_with(|| Painted::new(transform)).add(seen); + } + } + /// Ends the page. Some producers draw text twice, the copy for an outline, shadow or /// glow, and mark only one drawing with `/ActualText` (Canva). The unmarked redraw of a /// replacement's glyphs (the same text at the same places, in content order) takes the same @@ -259,8 +407,23 @@ fn text_debug() -> bool { impl<'a> Device<'a> for TextDevice { fn set_soft_mask(&mut self, _: Option>) {} fn set_blend_mode(&mut self, _: BlendMode) {} - fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} - fn push_clip_path(&mut self, _: &ClipPath) {} + fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'a>, _: &PathDrawMode) { + // A path is no text, but it places an `/ActualText` replacement that marks no glyph. + if self.actual.is_some() + && let Some(b) = control_box(path, transform) + { + self.paint(b, transform, self.excluded_depth.is_some()); + } + } + fn push_clip_path(&mut self, clip: &ClipPath) { + // Clip paths arrive in view space. NaN edges (a damaged clip) leave that edge unclipped. + let b = clip.path.bounding_box(); + let visible = match self.clips.last() { + Some(c) => Rect::new(c.x0.max(b.x0), c.y0.max(b.y0), c.x1.min(b.x1), c.y1.min(b.y1)), + None => b, + }; + self.clips.push(visible); + } fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, _: &Paint<'a>, mode: &GlyphDrawMode) { if self.excluded_depth.is_some() || (self.filter.images && matches!(mode, GlyphDrawMode::Invisible)) { @@ -309,13 +472,7 @@ impl<'a> Device<'a> for TextDevice { } else { (coefficients[0], coefficients[1]) }; - let dir = if dx.abs() >= dy.abs() { - if dx >= 0.0 { 0 } else { 2 } - } else if dy > 0.0 { - 1 - } else { - 3 - }; + let dir = quarter_turn(dx, dy); let em = Rect::new(0.0, -200.0, advance, 800.0); let corners = if matches!(glyph, Glyph::Outline(g) if g.is_vertical()) { [(em.x0, em.y1), (em.x0, em.y0), (em.x1, em.y0), (em.x1, em.y1)] @@ -328,8 +485,8 @@ impl<'a> Device<'a> for TextDevice { }); let outside_crop = b.x0.max(self.page.x0) >= b.x1.min(self.page.x1) || b.y0.max(self.page.y0) >= b.y1.min(self.page.y1); if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) - || b.width() > 10_000.0 - || b.height() > 10_000.0 + || b.width() > MAX_GLYPH_EXTENT + || b.height() > MAX_GLYPH_EXTENT || outside_crop { self.actual_cropped |= replaced && outside_crop; @@ -350,8 +507,23 @@ impl<'a> Device<'a> for TextDevice { None => self.glyphs.push((dir, direction, glyph)), } } - fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} - fn pop_clip_path(&mut self) {} + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + // A picture of words, like an outline, places a replacement that marks no glyph; with + // images excluded, it is not counted. + if self.actual.is_none() { + return; + } + // The transform places the picture's pixels, not a unit square. + let (w, h) = (f64::from(image.width()), f64::from(image.height())); + let corners = [(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)].map(|(x, y)| transform * kurbo::Point::new(x, y)); + let [a, b, c, d] = corners; + if corners.iter().all(|p| p.is_finite()) { + self.paint(Rect::from_points(a, c).union_pt(b).union_pt(d), transform, self.excluded_depth.is_some() || self.filter.images); + } + } + fn pop_clip_path(&mut self) { + self.clips.pop(); + } fn pop_transparency_group(&mut self) {} fn begin_marked_content(&mut self, tag: &[u8], mcid: Option) { self.depth = self.depth.saturating_add(1); @@ -374,6 +546,11 @@ impl<'a> Device<'a> for TextDevice { } self.depth = self.depth.saturating_sub(1); } + fn begin_annotations(&mut self) { + // A replacement the page content never closed ends with it: the annotations drawn next + // are not part of it. + self.end_actual_text(); + } } /// Extract the text of one page. Panics inside the interpreter are the caller's to catch. diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 8776dfeb72..f0bd9d864b 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -279,6 +279,207 @@ fn actual_text_follows_the_pdf_rules_for_marked_content() { assert_eq!(plain, ["ofice", "AB", "co op", "x\ny", "afi", "abc"]); } +#[test] +fn actual_text_over_painted_paths_reads_where_they_are_painted() { + // Words drawn as outlines (a face that can't be embedded) carry their text in /ActualText + // (ISO 32000-2 §14.9.4): it reads at the box of the paths it marks, in view space (y down). + let pdf = marked_pages(&[ + "/Span <> BDC 100 40 m 160 40 l 160 60 l 100 60 l f EMC", + // A curve is measured by its control points, as redaction measures it. + "/Span <> BDC 100 40 m 100 100 160 100 160 40 c f EMC", + "BT /F1 20 Tf 1 0 0 1 20 150 Tm (a) Tj ET /Span <> BDC 100 140 60 20 re f EMC BT /F1 20 Tf 1 0 0 1 200 150 Tm (c) Tj ET", + // Glyphs inside the span keep their own box; the paths beside them don't widen it. + "/Span <> BDC BT /F1 20 Tf 1 0 0 1 100 150 Tm (y) Tj ET 0 0 300 300 re f EMC", + // Paths are not text without a replacement, and an empty one stands for nothing. + "100 140 60 20 re f", + "/Span <> BDC 100 140 60 20 re f EMC", + ]); + let painted = marked_text(&pdf, 0); + assert_eq!(painted.plain_text(), "valid"); + assert_eq!(painted.glyphs[0].rect, [100.0, 240.0, 160.0, 260.0]); + assert_eq!(marked_text(&pdf, 1).glyphs[0].rect, [100.0, 200.0, 160.0, 260.0]); + let between = marked_text(&pdf, 2); + assert_eq!(between.glyphs.iter().map(|g| g.text.as_str()).collect::>(), ["a", "b", "c"]); + let mixed = marked_text(&pdf, 3); + assert_eq!(mixed.plain_text(), "x"); + assert!(mixed.glyphs[0].rect[2] - mixed.glyphs[0].rect[0] < 20.0, "{:?}", mixed.glyphs[0].rect); + for page in 4..6 { + assert_eq!(marked_text(&pdf, page).plain_text(), "", "page {page}"); + } +} + +#[test] +fn the_painted_filter_leaves_out_replacements_that_mark_no_glyph() { + // A page's own text, without words that only /ActualText names. + let pdf = marked_pages(&[ + "/Span <> BDC 100 40 m 160 40 l 160 60 l 100 60 l f EMC", + "BT /F1 20 Tf 1 0 0 1 20 150 Tm (a) Tj ET /Span <> BDC 100 140 60 20 re f EMC BT /F1 20 Tf 1 0 0 1 200 150 Tm (c) Tj ET", + ]); + let own = |page| extract_page_filtered(&pdf, page, &InterpreterSettings::default(), TextFilter { painted: true, ..Default::default() }); + assert_eq!(own(0).unwrap().plain_text(), ""); + let between = own(1).unwrap().plain_text(); + assert!(between.contains('a') && between.contains('c') && !between.contains('b'), "the glyphs stay: {between:?}"); +} + +#[test] +fn actual_text_over_paint_reads_only_where_it_shows() { + let pdf = marked_pages(&[ + // A page-sized fill clipped to a word's box reads at that box. + "q 100 40 30 20 re W n /Span <> BDC 0 0 300 300 re f EMC Q", + // Wholly clipped away, it reads nowhere. + "q 0 0 10 10 re W n /Span <> BDC 100 40 60 20 re f EMC Q", + // A clip that ended before the paint no longer cuts it. + "q 0 0 10 10 re W n Q /Span <> BDC 100 40 60 20 re f EMC", + ]); + assert_eq!(marked_text(&pdf, 0).glyphs[0].rect, [100.0, 240.0, 130.0, 260.0]); + assert_eq!(marked_text(&pdf, 1).plain_text(), ""); + assert_eq!(marked_text(&pdf, 2).glyphs[0].rect, [100.0, 240.0, 160.0, 260.0]); +} + +#[test] +fn actual_text_over_paint_reads_only_inside_the_crop_box() { + use lopdf::{Document, Object, Stream, dictionary}; + let (page, crop) = ([0, 0, 300, 300], [50, 60, 250, 240]); + let pages = [ + // Wholly outside the crop box, as a glyph there is: it reads nowhere. + ("/Span <> BDC 100 10 60 20 re f EMC", page, crop), + // Across its left edge: it reads from that edge, as a glyph across it still shows. + ("/Span <> BDC 20 100 60 20 re f EMC", page, crop), + // Partly painted outside it: its words may be among what the crop box hides. + ("/Span <> BDC 100 100 60 20 re f 100 10 60 20 re f EMC", page, crop), + // A glyph the crop takes leaves the replacement no glyph, and the paint can't place it. + ("/Span <> BDC BT /F1 12 Tf 1 0 0 1 100 10 Tm (x) Tj ET 100 100 60 20 re f EMC", page, crop), + // On a page wide enough to hold them, paint too far apart to be the place of words. + ("/Span <> BDC 100 40 10 10 re f 12100 40 10 10 re f EMC", [0, 0, 14400, 300], [0, 0, 14400, 300]), + ]; + let boxed = |b: [i64; 4]| b.map(Object::Integer).to_vec(); + let mut doc = Document::with_version("1.7"); + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" }); + let parent = doc.new_object_id(); + let mut kids = Vec::new(); + for (ops, media, crop) in pages { + let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec())); + kids.push(Object::Reference(doc.add_object(dictionary! { + "Type" => "Page", + "Parent" => parent, + "MediaBox" => boxed(media), + "CropBox" => boxed(crop), + "Resources" => dictionary! { "Font" => dictionary! { "F1" => font } }, + "Contents" => content, + }))); + } + let count = kids.len() as i64; + doc.objects.insert(parent, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => count }.into()); + let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => parent }); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + assert_eq!(marked_text(&pdf, 0).plain_text(), ""); + // View space starts at the crop box's top-left corner. + assert_eq!(marked_text(&pdf, 1).glyphs[0].rect, [0.0, 120.0, 30.0, 140.0]); + for page in 2..5 { + assert_eq!(marked_text(&pdf, page).plain_text(), "", "page {page}"); + } +} + +#[test] +fn actual_text_over_paint_reads_along_the_way_it_is_drawn() { + // The same quarter turn for a path and for text: the replacement runs the way the text does. + let pdf = marked_pages(&[ + "q 0 1 -1 0 150 150 cm /Span <> BDC 0 0 40 10 re f EMC Q", + "q 0 1 -1 0 150 150 cm BT /F1 10 Tf 0 0 Td (t) Tj ET Q", + ]); + let (painted, text) = (marked_text(&pdf, 0), marked_text(&pdf, 1)); + assert_eq!(painted.plain_text(), "turned"); + assert_eq!(painted.glyphs[0].direction, text.glyphs[0].direction); + let [x0, y0, x1, y1] = painted.glyphs[0].rect; + assert_eq!((x1 - x0, y1 - y0), (10.0, 40.0), "40 along the turned line, 10 across it"); +} + +#[test] +fn actual_text_over_a_picture_reads_where_it_is_drawn() { + // Two pixels square, so a box placed in pixels rather than across the picture shows. + let pdf = marked_pages(&["/Span <> BDC q 60 0 0 20 100 40 cm BI /W 2 /H 2 /CS /G /BPC 8 ID AAAA EI Q EMC"]); + let text = marked_text(&pdf, 0); + assert_eq!(text.plain_text(), "E=mc2"); + assert_eq!(text.glyphs[0].rect, [100.0, 240.0, 160.0, 260.0]); + // Counting without images leaves it out. + let filter = TextFilter { images: true, ..Default::default() }; + assert_eq!(extract_page_filtered(&pdf, 0, &InterpreterSettings::default(), filter).unwrap().plain_text(), ""); +} + +#[test] +fn excluded_glyphs_beside_paint_leave_the_whole_replacement_out() { + // The marked glyphs are a figure's, so the replacement goes with them; the paint beside + // them neither keeps it nor turns it into a mixed span that can't be counted. + let pdf = marked_pages(&[ + "/Span <> BDC /Figure BMC BT /F1 20 Tf 1 0 0 1 100 150 Tm (x) Tj ET EMC 100 40 60 20 re f EMC", + // Paint inside an excluded region is excluded with it. + "/Span <> BDC /Figure BMC 100 40 60 20 re f EMC EMC", + "/Span <> BDC /Table BMC q 60 0 0 20 100 40 cm BI /W 1 /H 1 /CS /G /BPC 8 ID A EI Q EMC EMC", + ]); + let filtered = |page, filter| extract_page_filtered(&pdf, page, &InterpreterSettings::default(), filter).unwrap().plain_text(); + let (figures, tables) = (TextFilter { images: true, ..Default::default() }, TextFilter { tables: true, ..Default::default() }); + assert_eq!(filtered(0, figures.clone()), ""); + assert_eq!((filtered(1, TextFilter::default()), filtered(1, figures)), ("z".to_string(), String::new())); + assert_eq!((filtered(2, TextFilter::default()), filtered(2, tables)), ("t".to_string(), String::new())); +} + +#[test] +fn paint_split_by_an_exclusion_is_a_mixed_span_that_cant_be_counted() { + // Paint places a replacement that marks no glyph, so paint left out mixes it as excluded + // glyphs do: the words left out can't be told from the rest. + let picture = "q 60 0 0 20 100 40 cm BI /W 1 /H 1 /CS /G /BPC 8 ID A EI Q"; + let (table, beside) = ( + format!("/Span <> BDC /Table BMC {picture} EMC 200 40 60 20 re f EMC"), + format!("/Span <> BDC {picture} 200 40 60 20 re f EMC"), + ); + let pdf = marked_pages(&[ + "/Span <> BDC /Figure BMC 100 40 60 20 re f EMC 200 40 60 20 re f EMC", + &table, + &beside, + // Glyphs place this one, and the paint beside them is not its words. + "/Span <> BDC BT /F1 20 Tf 1 0 0 1 100 150 Tm (y) Tj ET /Figure BMC 100 40 60 20 re f EMC EMC", + ]); + let filtered = |page, filter| extract_page_filtered(&pdf, page, &InterpreterSettings::default(), filter).map(|t| t.plain_text()); + let (figures, tables) = (TextFilter { images: true, ..Default::default() }, TextFilter { tables: true, ..Default::default() }); + for (page, text, filter) in [(0, "w", figures.clone()), (1, "t", tables), (2, "p", figures.clone())] { + assert_eq!(filtered(page, TextFilter::default()), Ok(text.to_string())); + assert!(filtered(page, filter).is_err(), "page {page}"); + } + assert_eq!(filtered(3, figures), Ok("g".to_string())); +} + +#[test] +fn annotations_never_join_a_replacement_the_page_left_open() { + use lopdf::{Document, Object, Stream, dictionary}; + let mut doc = Document::with_version("1.7"); + let pages = doc.new_object_id(); + let content = doc.add_object(Stream::new(dictionary! {}, b"/Span <> BDC 100 40 60 20 re f".to_vec())); + let ap = doc.add_object(Stream::new( + dictionary! { "Type" => "XObject", "Subtype" => "Form", "BBox" => vec![0.into(), 0.into(), 50.into(), 50.into()] }, + b"0 0 50 50 re f".to_vec(), + )); + let annot = doc.add_object(dictionary! { + "Type" => "Annot", "Subtype" => "Square", "F" => 4, + "Rect" => vec![200.into(), 200.into(), 250.into(), 250.into()], "AP" => dictionary! { "N" => ap }, + }); + let page = doc.add_object(dictionary! { + "Type" => "Page", "Parent" => pages, "Contents" => content, "Annots" => vec![Object::Reference(annot)], + "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(300), Object::Integer(300)], + }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let text = marked_text(&pdf, 0); + assert_eq!(text.plain_text(), "open"); + assert_eq!(text.glyphs[0].rect, [100.0, 240.0, 160.0, 260.0], "the square comment is not part of it"); +} + #[test] fn an_unmarked_redraw_of_actual_text_reads_as_the_same_text() { // Canva draws text twice (an outline or shadow) and marks only one drawing. The copy, diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml index 3c0f207a98..bfb04a26cb 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } pdfcraft-annot = { workspace = true } +pdfcraft-content = { workspace = true } pdfcraft-fonts = { workspace = true } pdfcraft-model = { workspace = true } thiserror.workspace = true @@ -41,6 +42,9 @@ sha3 = "0.12.0" ripemd = "0.2.0" ed25519-dalek = "3.0.0" +[dev-dependencies] +pdfcraft-testkit = { workspace = true } + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] aws-lc-rs = { version = "1.18", default-features = false, features = ["aws-lc-sys", "prebuilt-nasm"] } getrandom = "0.4" diff --git a/crates/sign/README.md b/crates/sign/README.md index 6844b1a6ec..126e938786 100644 --- a/crates/sign/README.md +++ b/crates/sign/README.md @@ -50,6 +50,10 @@ for s in signatures(&doc, &bytes, &trust) { // li RFC 6960 OCSP responses (responder identity, OCSP-signing EKU for delegated responders, CertID hash matching, validity windows); validation checks embedded evidence against the signer's chain and a verified revocation invalidates the signature. +- **Status layers:** `show_status_layers` draws each signature's validated state (icon, and a + caption in the caller's words) into named-layer appearances, whose `/N` draws one layer form + that holds and draws `n1` and `n3`; `has_status_layers` says whether a document has any. It + works on a display copy and never edits an original object, so saving keeps the signed bytes. Not yet: revocation fetching (AIA/CRLDP extraction and a fetcher), timestamp-server configuration, FieldMDP locks, certificate security, direct PKCS #11 access to tokens. diff --git a/crates/sign/src/lib.rs b/crates/sign/src/lib.rs index 2954de288d..a3a35540a0 100644 --- a/crates/sign/src/lib.rs +++ b/crates/sign/src/lib.rs @@ -19,6 +19,7 @@ pub mod pdf; pub mod pkcs12; pub mod revocation; mod rsa_pad; +pub mod status_layers; pub mod timestamp; #[cfg(target_os = "windows")] pub mod windows; @@ -32,6 +33,7 @@ pub use pdf::{ in_english, kind, list as signatures, sign, sign_with_timestamp, timestamp_document, validate, }; pub use pkcs12::DigitalId; +pub use status_layers::{StatusCaptions, has_status_layers, show_status_layers, status_rgb}; pub use timestamp::{TimestampAuthority, TimestampQuery, TimestampToken}; pub use x509::{Certificate, Name}; diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 8fafff7f01..77678f2c83 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -32,8 +32,11 @@ mod validate; #[cfg(test)] use changes::only_grew; #[cfg(test)] -use listing::{DISCOVERY_CACHE_LIMIT, is_document_timestamp}; +pub(crate) use listing::MAX_FIELD_NAME_BYTES; +#[cfg(test)] +use listing::{DISCOVERY_CACHE_LIMIT, Field, MAX_ENTRIES, MAX_FIELDS, MAX_NAME_BYTES, is_document_timestamp, sig_fields}; pub use listing::{DigestCache, list, list_cached}; +pub(crate) use listing::{walk_sig_fields, widget_shown}; pub(crate) use signing::fmt; pub use signing::{Appearance, AppearanceLabels, SignOptions, display_date, sign, sign_with_timestamp, timestamp_document}; pub use types::{Detail, Fill, Modification, Status, TrustStore, WORDINGS, Words, in_english, kind}; @@ -46,6 +49,9 @@ use validate::{DssEvidence, MAX_DSS_BYTES, MAX_DSS_ENTRIES}; #[path = "pdf_discovery_tests.rs"] mod discovery_tests; #[cfg(test)] +#[path = "pdf_fields_tests.rs"] +mod fields_tests; +#[cfg(test)] #[path = "pdf/revision_classification_tests.rs"] mod revision_classification_tests; @@ -89,6 +95,11 @@ pub struct SignatureInfo { pub timestamp_time: Option