mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
* M9: redraw named-layer signature status layers - /N drawing one Form that holds n1 and n3: n1, n3 and n4 redrawn for the state - copies made once per original and state; originals never edited - Lucide icons; captions in Helvetica, embedded Inter, or craft-fonts outlines Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: draw signature states in the display copy, exports and print - marks follow the bytes shown; a changed verdict redraws after a metadata edit - background opens pick up the trust and captions current at commit Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: status captions in the interface language - the three captions in all 22 catalogues - Signatures panel colours from sign::status_rgb Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: signature badge in the widget's corner - a quarter of the shorter side, 48pt cap; outline only under 32pt - dropped for a drawn valid state; bottom-right where the appearance draws one - sign_list reports verdict_shown; parity and roadmap Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: keep Arabic signature captions in logical order Pass raw RTL catalogue text to the signature appearance shaper so UAX #9 is applied exactly once. This keeps the rendered word order and exported /ActualText consistent. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M2: read /ActualText over painted paths and pictures - A replacement that marks no glyph reads where it paints, cut to what is visible and along the way it is drawn. - A span the page content leaves open ends before the annotations. - OCR and scan field detection still count such a page as having no text of its own. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M8: redaction strips /ActualText around paint it clips - A path or shading written again under a clip counts as changed, so a partly redacted word drawn as outlines leaves no name in the file. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: recognise only layer forms that draw n1 and n3 - Settle on the one form holding both first, then read its content once per call. - Skip an /N that is its own layer form; copies keep the layer's /OC. - A verdict counts as drawn when any visible widget shows it. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: keep the signature badge and redraw wherever a state changes - The badge stays for every state; its mark sits bottom-right as the widget lies on the page. - An edit that changes a verdict redraws every page; captions apply before startup files open. - Print, the CLI's render and the repair log follow the display copy. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: restore the attribution usage the rebase dropped - Usage lines for the three icons, Inter and the craft-fonts input; ATTRIBUTION.md rebuilt from them. - Roadmap and localization notes for this round. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: pin signature field names on a nested form Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: bound the signature field walk - kids share their parent's name and type instead of each copying them - a widget stays listed under every field that claims it, as one shared copy - a field written in place in a shared /Kids array is walked once - caps of 20,000 fields, 100,000 entries and 4096-byte names; no states drawn past them Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: keep a document signed when the field walk stops early - list_cached adds a signed row of unknown validity for the fields the walk didn't read - Save As and actions then append, and Optimize, Sanitize and full rewrites are refused, as for any signed file Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M8: removing hidden content no longer strips the names of shadings - With no regions to clip, a shading is left alone instead of wrapped and marked clipped - clean_replacements skips scopes already marked, so deep nesting stays linear Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M2: read a picture's /ActualText over the whole picture - draw_image's transform maps pixels, not a unit square, so the text sat on one pixel in the corner; the test now draws a 2x2 picture - Painted grows in place instead of being copied through an Option Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: cap the bytes of names the signature field walk keeps - A kid's name repeats its parent's, so one long name over 20,000 kids came to 80 MB; the walk now stops at 4 MiB of names and counts the rest as unread - Cover the widget written into a shared /Kids array and the field cap among kids, which no test caught - sig_fields is private again, and the cap docs say what happens past each cap Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: skip status-layer work that changes nothing; fix tests that could not fail - show_status_layers and has_status_layers return before reading the form when nothing is signed, so an unsigned form no longer walks every field on each edit - revalidate and draw_captions leave documents with no drawn state alone, and no longer copy the signature list to update it - Scan form detection reads the page's text once; comment and badge tips share one hover rule - Tests: drop a table row that repeated another, stop taking an expectation from the code under test, stop comparing an Arc with itself, give bare asserts messages Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: test the signature field name cap at the engine boundary - A 4,096-byte name is read and blocks a full rewrite; at 4,097 bytes the list holds the unread-fields row and a full rewrite is still refused Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: test signature states beside fields the viewer draws - One display copy holds both a field's drawn appearance and the signature's state. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: draw Thai and Devanagari status captions in their own script - script_of names Thai and Devanagari, so a craft-fonts face meant for either draws the caption as outlines; without one it stays English. - Localization, parity and the craft-fonts usage line list the new scripts. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M2: paint left out by an exclusion mixes its /ActualText replacement - A replacement placed by outlines or pictures, some of them excluded, fails filtered extraction as one with excluded glyphs does. - Glyphs still place a replacement that has them. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: a long-named field that can't hold a signature leaves the walk whole - A leaf of another type passed over for its name is no signature field and has none below it, so an unsigned form stays unsigned. - A signature field, or any field with kids, still counts as unread. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: status captions use document faces only - Each script takes the face PdfCraft writes it into documents with, so Chinese stays English although the pinned craft-fonts' interface-only Chinese face is meant for Hani. - The craft-fonts usage line names Devanagari: the pinned revision has a face for it, and Hindi captions are drawn in it. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: refuse a new signature field the field walk can't read back - Signing adds a field only when the walk reads the whole form with it: past a cap the signature would be listed as unread fields, and its name could repeat an unread field's. - A field the walk already reads is signed as before. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: refuse a new signature field with a blank name - The walk lists no field without a name, so signing now also requires the walk to list the new field: one named only with spaces or NULs would be a signature nothing shows. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: an unnamed signed field or an unreadable form counts as unread - A signed field with no usable /T, or an /AcroForm or /Fields of the wrong type, cuts the field walk short: the document keeps its unread-fields row and stays signed. - Signing refuses a blank new field name before it adds the field, so the refusal still says why. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: no display copy when no signature state is drawn - display_bytes_for makes a copy only when it added an appearance or drew a state, as the app does. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: the verdict mark stays on the page - A widget past the crop box takes its mark in the part on the page, or none when too little of it is there. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: typed values stay drawn while a signature redraws every page - A form edit that changes a signature's state puts its values back in unbaked after the pages are invalidated. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: the action, Save As and shading tests check their effects - The signed action and Save As tests reopen the output for the title and the comment; the shading test checks its clip. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: values typed earlier stay drawn while a signature redraws every page - A form edit that changes a signature's state keeps every value still waiting on its page, not only its own. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: comment and content edits keep typed values drawn too - An edit to one page's comments or content that changes a signature's state moves no page, so the values still waiting on their pages stay pending, as after a form edit. - The pending-value test runs a field deletion and a comment after a fill, under a certification that allows the fill; its messages say what it checks. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: the language-change test skips without catalogs - Like the other language tests, it returns early when the build has no craft-i18n catalogs. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: signed fields behind kids without a name count - A kid with no /T but kids of its own is walked as a field node, so a signed field under it is listed by its full name. - An unnamed kid claiming a signature its field doesn't have cuts the walk short, so the document stays signed and keeps its unread-fields row. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> --------- Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
This commit is contained in:
1 parent
f841666d5f
commit
adbf1dc01f
56 files changed
+4687
-197
No files matched your search
+5
-5
@@ -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/<code>/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 |
|
||||
+5
-5
@@ -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"
|
||||
|
||||
Generated
+2
@@ -4687,9 +4687,11 @@ dependencies = [
|
||||
"p521",
|
||||
"pbkdf2",
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-testkit",
|
||||
"rc2",
|
||||
"ripemd",
|
||||
"rsa",
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -896,7 +896,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
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(
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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<String>,
|
||||
/// The working file: what Save writes.
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
/// 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<Vec<u8>>,
|
||||
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<Vec<SignatureInfo>>,
|
||||
pub(crate) trust: Arc<TrustStore>,
|
||||
/// 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<StatusCaptions>,
|
||||
pub(crate) sig_cache: Arc<pdfcraft_sign::DigestCache>,
|
||||
pub(crate) editor: Option<Editor>,
|
||||
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<Vec<u8>>) -> Arc<Vec<u8>> {
|
||||
/// 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<Vec<u8>>,
|
||||
signatures: &mut [SignatureInfo],
|
||||
captions: &StatusCaptions,
|
||||
) -> Arc<Vec<u8>> {
|
||||
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<Vec<u8>>,
|
||||
display: &pdfcraft_cos::Document,
|
||||
signatures: &mut [SignatureInfo],
|
||||
) -> Arc<Vec<u8>> {
|
||||
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<pdfcraft_cos::Document>, doc: &pdfcraft_cos::Document) -> Option<pdfcraft_cos::Document> {
|
||||
@@ -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<Vec<u8>>, password: Option<&str>) -> Arc<Vec<u8>> {
|
||||
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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<pdfcraft_render::PageText, String> {
|
||||
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<Vec<u8>> {
|
||||
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 <</ActualText (APPROVED)>> 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]
|
||||
|
||||
@@ -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<i64>,
|
||||
js_off: bool,
|
||||
trust: Arc<TrustStore>,
|
||||
captions: Arc<StatusCaptions>,
|
||||
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<String> = 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<TrustStore>, 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,
|
||||
|
||||
@@ -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<fn() -> i64>,
|
||||
/// Certificates trusted for signing (Acrobat: Trusted Certificates).
|
||||
pub(crate) trust: Arc<TrustStore>,
|
||||
/// The words signature status layers are drawn with, in the interface language.
|
||||
pub(crate) captions: Arc<StatusCaptions>,
|
||||
/// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default).
|
||||
pub(crate) js_off: bool,
|
||||
/// Preferences ▸ Date format, when not the default (see [`dates`]).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<pdfcraft_sign::Certificate>) {
|
||||
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<TrustStore>) {
|
||||
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<StatusCaptions>) {
|
||||
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();
|
||||
|
||||
+635
-1
@@ -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 <</ActualText (SECRET42)>> 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<u8> {
|
||||
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<u8> {
|
||||
use pdfcraft_cos::{Dict, Object, PdfString};
|
||||
let reserve = 8192 + id.certificate.raw.len() + id.chain.iter().map(|c| c.raw.len()).sum::<usize>();
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<Vec<u8>>, 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<Vec<u8>> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -147,6 +147,16 @@ pub fn arabic_glyph(id: u32) -> Result<GlyphOutline, GlyphError> {
|
||||
/// A bounded outline from the same face that produced the shaped glyph id.
|
||||
pub fn shaped_glyph(script: ShapedScript, id: u32) -> Result<GlyphOutline, GlyphError> {
|
||||
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<GlyphOutline, GlyphError> {
|
||||
glyph(&FontRef::new(bytes).map_err(|_| GlyphError::NoFont)?, id)
|
||||
}
|
||||
|
||||
fn glyph(font: &FontRef, id: u32) -> Result<GlyphOutline, GlyphError> {
|
||||
if font.maxp().map_or(true, |m| id >= u32::from(m.num_glyphs())) {
|
||||
return Err(GlyphError::Missing);
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<u8>],
|
||||
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<Op>,
|
||||
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<usize>,
|
||||
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<Op> {
|
||||
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<usize>, op: Op, piece: &mut Piece) -> Option<Op> {
|
||||
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<Op>, 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<usize>,
|
||||
processed: &mut [(Vec<Op>, bool)],
|
||||
report: &mut Report,
|
||||
) {
|
||||
let originals: HashMap<usize, &Op> = original.iter().map(|op| (op.span.start, op)).collect();
|
||||
let unchanged: HashSet<usize> =
|
||||
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())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <</ActualText (SECRET)>> BDC 100 100 m 160 100 l 160 120 l 100 120 l h f EMC /Span <</ActualText (KEPT)>> 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 <</ActualText (SHADED)>> 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 <</ActualText (CLIPS)>> 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 <</ActualText (OUTER)>> BDC 100 100 m 160 100 l 160 120 l 100 120 l h f /Span <</ActualText (INNER)>> 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 <</Alt (chart)>> 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.
|
||||
|
||||
@@ -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<i32>,
|
||||
}
|
||||
|
||||
@@ -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<Drawn>)>,
|
||||
/// 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<Painted>,
|
||||
/// 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<Rect>,
|
||||
/// Every `/ActualText` replacement made on the page.
|
||||
pub(super) replaced: Vec<Replaced>,
|
||||
/// Visible page bounds in view space (`intersected_crop_box` after `initial_transform`).
|
||||
@@ -105,12 +115,109 @@ fn merge(drawn: &[Drawn], text: String) -> Option<Drawn> {
|
||||
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<Drawn> {
|
||||
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<Rect> {
|
||||
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<Rect> {
|
||||
let mut b: Option<Rect> = 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<SoftMask<'a>>) {}
|
||||
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<SoftMask<'a>>, _: 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<i32>) {
|
||||
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.
|
||||
|
||||
@@ -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 <</ActualText (valid)>> 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 <</ActualText (arc)>> 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 <</ActualText (b)>> 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 <</ActualText (x)>> 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 <</ActualText ()>> 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::<Vec<_>>(), ["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 <</ActualText (valid)>> 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 <</ActualText (b)>> 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 <</ActualText (c)>> BDC 0 0 300 300 re f EMC Q",
|
||||
// Wholly clipped away, it reads nowhere.
|
||||
"q 0 0 10 10 re W n /Span <</ActualText (gone)>> 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 <</ActualText (back)>> 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 <</ActualText (below)>> 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 <</ActualText (edge)>> 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 <</ActualText (part)>> 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 <</ActualText (split)>> 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 <</ActualText (far)>> 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 <</ActualText (turned)>> 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 <</ActualText (E=mc2)>> 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 <</ActualText (f)>> 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 <</ActualText (z)>> BDC /Figure BMC 100 40 60 20 re f EMC EMC",
|
||||
"/Span <</ActualText (t)>> 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 <</ActualText (t)>> BDC /Table BMC {picture} EMC 200 40 60 20 re f EMC"),
|
||||
format!("/Span <</ActualText (p)>> BDC {picture} 200 40 60 20 re f EMC"),
|
||||
);
|
||||
let pdf = marked_pages(&[
|
||||
"/Span <</ActualText (w)>> 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 <</ActualText (g)>> 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 <</ActualText (open)>> 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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
+12
-1
@@ -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<Time>,
|
||||
pub status: Status,
|
||||
pub modification: Modification,
|
||||
/// The display copy draws this verdict into the appearance of at least one of the
|
||||
/// signature's visible widgets (its named status layers, see [`crate::status_layers`]). The
|
||||
/// document can still clip those layers away or cover them, so this never stands in for the
|
||||
/// page's own validation badge. [`list`] leaves it false; [`crate::show_status_layers`] sets it.
|
||||
pub verdict_shown: bool,
|
||||
/// Acrobat-style sentences explaining the verdict, each one ready to read in English and ready to
|
||||
/// be said in another language (see [`Detail`]).
|
||||
pub details: Vec<Detail>,
|
||||
|
||||
+241
-37
@@ -1,4 +1,6 @@
|
||||
use super::types::{Modification, Status, TrustStore};
|
||||
use std::rc::Rc;
|
||||
|
||||
use super::types::{Detail, Modification, Status, TrustStore};
|
||||
use super::validate::{is_doc_timestamp, validate_doc_timestamp, validate_into};
|
||||
use super::{Arc, Dict, DigestAlg, Document, HashMap, HashSet, ObjRef, Object, SignatureInfo};
|
||||
|
||||
@@ -176,12 +178,16 @@ pub(crate) fn text(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
pub(super) struct Field {
|
||||
pub(super) name: String,
|
||||
/// A signature field [`walk_sig_fields`] found.
|
||||
pub(crate) struct Field {
|
||||
/// Fully qualified: the `/T` parts down the tree joined with dots, at most
|
||||
/// [`MAX_FIELD_NAME_BYTES`].
|
||||
pub(crate) name: String,
|
||||
/// The field dictionary (and its widget(s)).
|
||||
pub(super) r: Option<ObjRef>,
|
||||
pub(super) dict: Dict,
|
||||
pub(super) widgets: Vec<(Option<ObjRef>, Dict)>,
|
||||
/// A widget several fields list is listed under each of them, as one shared copy.
|
||||
pub(crate) widgets: Vec<(Option<ObjRef>, Rc<Dict>)>,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
@@ -198,46 +204,191 @@ impl Field {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every signature field, depth first in `/Fields` order.
|
||||
/// Most fields looked at for signature fields; real forms have far fewer. Past it no more are
|
||||
/// taken, and [`list_cached`] stands for the ones not read with one row ([`unread_fields`]).
|
||||
pub(super) const MAX_FIELDS: usize = 20_000;
|
||||
/// Most entries read from `/Fields` and all the `/Kids` arrays together: room for a few widgets
|
||||
/// for each of [`MAX_FIELDS`]. A `/Kids` array several fields list is read under each of them, so
|
||||
/// this bounds the walk where the field count can't. Past it no more entries are read: fields
|
||||
/// already taken are still listed, without the widgets past it.
|
||||
pub(super) const MAX_ENTRIES: usize = 100_000;
|
||||
/// Longest fully qualified field name read, in bytes. Real names are far shorter; a field named
|
||||
/// longer is passed over, and counts as unread as fields past [`MAX_FIELDS`] do unless it is a leaf
|
||||
/// of another type.
|
||||
pub(crate) const MAX_FIELD_NAME_BYTES: usize = 4096;
|
||||
/// Most bytes of names the walk keeps, all the fields it lists together. A kid's name repeats its
|
||||
/// parent's, so under the caps above one long name over many kids would still come to 80 MB.
|
||||
pub(super) const MAX_NAME_BYTES: usize = 4 << 20;
|
||||
|
||||
/// Every signature field the walk reads ([`walk_sig_fields`] has its caps), depth first in
|
||||
/// `/Fields` order.
|
||||
pub(super) fn sig_fields(doc: &Document) -> Vec<Field> {
|
||||
walk_sig_fields(doc).0
|
||||
}
|
||||
|
||||
/// [`sig_fields`], and whether the walk read the whole form. It did not when a cap stopped it, a
|
||||
/// field that is or holds a signature field was passed over for its name, a signature had no named
|
||||
/// field to be listed under (a signed field without a usable /T, or a kid without one claiming a
|
||||
/// signature its field doesn't have), or the form or its `/Fields` was there but of the wrong type:
|
||||
/// a field it did not read may be signed, or claim one of the widgets listed.
|
||||
///
|
||||
/// Kids share their parent's name instead of each copying it. A kid with no /T but kids of its own
|
||||
/// is a field all the same, which adds nothing to the names below it. A widget is listed under every field
|
||||
/// that lists it, so two claims on one widget stay visible, and it is copied once. A field taken
|
||||
/// by reference is taken where the walk first reaches it; one written in place in an indirect
|
||||
/// array is queued once, however many fields list that array.
|
||||
pub(crate) fn walk_sig_fields(doc: &Document) -> (Vec<Field>, bool) {
|
||||
let mut out = Vec::new();
|
||||
let Some(root) = doc.root() else { return out };
|
||||
let Some(af) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()) else {
|
||||
return out;
|
||||
};
|
||||
let Some(fields) = af.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else { return out };
|
||||
let Some(root) = doc.root() else { return (out, true) };
|
||||
// A form that isn't there holds no fields; one that is there but isn't a dictionary may hold any.
|
||||
let Some(form) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return (out, true) };
|
||||
let Some(af) = form.as_dict() else { return (out, matches!(*form, Object::Null)) };
|
||||
let Some(top) = af.get(b"Fields") else { return (out, true) };
|
||||
let (mut fields_left, mut entries_left, mut names_left, mut whole) = (MAX_FIELDS, MAX_ENTRIES, MAX_NAME_BYTES, true);
|
||||
// Fields by reference, and by slot those written in place in an indirect array.
|
||||
let mut seen = HashSet::new();
|
||||
let mut stack: Vec<(Object, String, Option<Vec<u8>>)> = fields.into_iter().rev().map(|f| (f, String::new(), None)).collect();
|
||||
while let Some((o, prefix, inherited_ft)) = stack.pop() {
|
||||
let mut copies: HashMap<(ObjRef, Option<usize>), Rc<Dict>> = HashMap::new();
|
||||
let unnamed: Rc<str> = Rc::from("");
|
||||
let mut stack: Vec<(Object, Rc<str>, bool)> = Vec::new();
|
||||
let fields = doc.resolve(top);
|
||||
if !matches!(*fields, Object::Null | Object::Array(_)) {
|
||||
return (out, false);
|
||||
}
|
||||
for (i, f) in fields.as_array().into_iter().flatten().enumerate() {
|
||||
if !take(&mut entries_left) {
|
||||
whole = false;
|
||||
break;
|
||||
}
|
||||
// A field written in place here is marked, so one that also lists this array as its /Kids
|
||||
// does not walk it again.
|
||||
if let Some(s) = slot(f, top.as_ref(), i) {
|
||||
seen.insert(s);
|
||||
}
|
||||
if !take(&mut fields_left) {
|
||||
whole = false;
|
||||
break;
|
||||
}
|
||||
stack.push((f.clone(), unnamed.clone(), false));
|
||||
}
|
||||
stack.reverse();
|
||||
while let Some((o, prefix, inherited_sig)) = stack.pop() {
|
||||
if let Some(r) = o.as_ref()
|
||||
&& !seen.insert(r)
|
||||
&& !seen.insert((r, None))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(d) = doc.resolve(&o).as_dict().cloned() else { continue };
|
||||
let part = text(doc, &d, b"T");
|
||||
let name = match (&part, prefix.is_empty()) {
|
||||
(Some(p), true) => p.clone(),
|
||||
(Some(p), false) => format!("{prefix}.{p}"),
|
||||
(None, _) => prefix.clone(),
|
||||
};
|
||||
let ft = d.name(b"FT").map(<[u8]>::to_vec).or(inherited_ft);
|
||||
let kids: Vec<Object> = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
// Kids with a /T are fields; others are this field's widgets.
|
||||
let (sub, widgets): (Vec<Object>, Vec<Object>) = kids.into_iter().partition(|k| doc.resolve(k).as_dict().is_some_and(|kd| kd.contains(b"T")));
|
||||
for k in sub.into_iter().rev() {
|
||||
stack.push((k, name.clone(), ft.clone()));
|
||||
let sig = d.name(b"FT").map_or(inherited_sig, |ft| ft == b"Sig");
|
||||
// Passed over for its name, a leaf of another type hides no signature: it is none, and
|
||||
// has no kids to hold one.
|
||||
let hides = sig || d.contains(b"Kids");
|
||||
// Even as UTF-16, a name within the cap takes at most twice its length plus a two-byte
|
||||
// mark. A /T stored in more is passed over undecoded, whatever it would trim to: a long
|
||||
// string named by many fields would otherwise be decoded once for each.
|
||||
let stored = d.get(b"T").map(|t| doc.resolve(t)).map_or(0, |t| match &*t {
|
||||
Object::String(s) => s.bytes.len(),
|
||||
Object::Name(n) => n.len(),
|
||||
_ => 0,
|
||||
});
|
||||
if stored > 2 * MAX_FIELD_NAME_BYTES + 2 {
|
||||
whole &= !hides;
|
||||
continue;
|
||||
}
|
||||
if ft.as_deref() == Some(b"Sig") && part.is_some() {
|
||||
let mut ws: Vec<(Option<ObjRef>, Dict)> =
|
||||
widgets.iter().filter_map(|w| doc.resolve(w).as_dict().cloned().map(|wd| (w.as_ref(), wd))).collect();
|
||||
if d.name(b"Subtype") == Some(b"Widget") {
|
||||
ws.insert(0, (o.as_ref(), d.clone()));
|
||||
let part = text(doc, &d, b"T");
|
||||
let name: Rc<str> = match (&part, prefix.is_empty()) {
|
||||
(Some(p), true) => p.as_str().into(),
|
||||
(Some(p), false) => format!("{prefix}.{p}").into(),
|
||||
(None, _) => prefix,
|
||||
};
|
||||
if name.len() > MAX_FIELD_NAME_BYTES {
|
||||
whole &= !hides;
|
||||
continue;
|
||||
}
|
||||
let listed = sig && part.is_some();
|
||||
// Signed, but with no name to be listed under: the signature is there all the same.
|
||||
if sig && part.is_none() && holds_signature(doc, &d) {
|
||||
whole = false;
|
||||
}
|
||||
let mut ws: Vec<(Option<ObjRef>, Rc<Dict>)> = Vec::new();
|
||||
if listed && d.name(b"Subtype") == Some(b"Widget") {
|
||||
ws.push((o.as_ref(), Rc::new(d.clone())));
|
||||
}
|
||||
let array = d.get(b"Kids").and_then(Object::as_ref);
|
||||
let kids = d.get(b"Kids").map(|k| doc.resolve(k));
|
||||
let mut sub = Vec::new();
|
||||
for (i, k) in kids.as_deref().and_then(Object::as_array).into_iter().flatten().enumerate() {
|
||||
if !take(&mut entries_left) {
|
||||
whole = false;
|
||||
break;
|
||||
}
|
||||
out.push(Field { name, r: o.as_ref(), dict: d, widgets: ws });
|
||||
let resolved;
|
||||
let kd = match k {
|
||||
Object::Ref(r) => {
|
||||
resolved = doc.get(*r);
|
||||
resolved.as_dict()
|
||||
}
|
||||
direct => direct.as_dict(),
|
||||
};
|
||||
let Some(kd) = kd else { continue };
|
||||
let at = slot(k, array, i);
|
||||
// Kids with a /T are fields, and so are those with kids of their own; the others are
|
||||
// this field's widgets.
|
||||
if kd.contains(b"T") || kd.contains(b"Kids") {
|
||||
if at.is_some_and(|s| !seen.insert(s)) {
|
||||
continue;
|
||||
}
|
||||
if !take(&mut fields_left) {
|
||||
whole = false;
|
||||
continue;
|
||||
}
|
||||
sub.push(k.clone());
|
||||
} else if listed {
|
||||
// A widget signed apart from its field holds a signature nothing lists.
|
||||
if holds_signature(doc, kd) && kd.get(b"V") != d.get(b"V") {
|
||||
whole = false;
|
||||
}
|
||||
let w = match k.as_ref().map(|r| (r, None)).or(at) {
|
||||
Some(key) => copies.entry(key).or_insert_with(|| Rc::new(kd.clone())).clone(),
|
||||
None => Rc::new(kd.clone()),
|
||||
};
|
||||
ws.push((k.as_ref(), w));
|
||||
} else if kd.name(b"FT") == Some(b"Sig") || holds_signature(doc, kd) {
|
||||
// A signature field and its widget in one, with no name, under a field that isn't
|
||||
// a signature field: nothing lists it, yet it may be signed.
|
||||
whole = false;
|
||||
}
|
||||
}
|
||||
for k in sub.into_iter().rev() {
|
||||
stack.push((k, name.clone(), sig));
|
||||
}
|
||||
if listed {
|
||||
let Some(left) = names_left.checked_sub(name.len()) else {
|
||||
whole = false;
|
||||
break;
|
||||
};
|
||||
names_left = left;
|
||||
out.push(Field { name: name.to_string(), r: o.as_ref(), dict: d, widgets: ws });
|
||||
}
|
||||
}
|
||||
out
|
||||
(out, whole)
|
||||
}
|
||||
|
||||
/// The slot of an entry written in place in an indirect array, which tells it apart when several
|
||||
/// fields list that array. An entry by reference, or in a direct array (one owner), has none.
|
||||
fn slot(entry: &Object, array: Option<ObjRef>, i: usize) -> Option<(ObjRef, Option<usize>)> {
|
||||
array.filter(|_| entry.as_ref().is_none()).map(|a| (a, Some(i)))
|
||||
}
|
||||
|
||||
/// Whether `d`'s own /V is a signature dictionary.
|
||||
fn holds_signature(doc: &Document, d: &Dict) -> bool {
|
||||
d.get(b"V").is_some_and(|v| doc.resolve(v).as_dict().is_some())
|
||||
}
|
||||
|
||||
/// Take one from a budget; false once it is spent.
|
||||
fn take(left: &mut usize) -> bool {
|
||||
let some = *left > 0;
|
||||
*left = left.saturating_sub(1);
|
||||
some
|
||||
}
|
||||
|
||||
/// Which page holds each annotation.
|
||||
@@ -253,11 +404,25 @@ fn annot_pages(doc: &Document) -> HashMap<ObjRef, usize> {
|
||||
map
|
||||
}
|
||||
|
||||
/// Whether a signature widget is shown on its page: it isn't Hidden (`/F` bit 2) or NoView (bit 6),
|
||||
/// as the forms crate reads a widget's flags, and its `/Rect` is four finite numbers whose box,
|
||||
/// corners in either order, is wider and taller than half a point. The page badge
|
||||
/// ([`SignatureInfo::visible`]) and the redrawn status layers ([`crate::show_status_layers`]) both
|
||||
/// go by it, so they never disagree about a widget.
|
||||
pub(crate) fn widget_shown(doc: &Document, widget: &Dict) -> bool {
|
||||
let flags = widget.get(b"F").and_then(|o| doc.resolve(o).as_int()).unwrap_or(0);
|
||||
let spans = |a: f64, b: f64| (a - b).abs() > 0.5;
|
||||
flags & (2 | 32) == 0
|
||||
&& nums(doc, widget, b"Rect").is_some_and(|r| r.len() == 4 && r.iter().all(|v| v.is_finite()) && spans(r[0], r[2]) && spans(r[1], r[3]))
|
||||
}
|
||||
|
||||
pub(super) fn nums(doc: &Document, d: &Dict, key: &[u8]) -> Option<Vec<f64>> {
|
||||
doc.resolve(d.get(key)?).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())
|
||||
}
|
||||
|
||||
/// List and validate every signature field. `bytes` is the file as stored (the ranges index it).
|
||||
/// A form not read whole ([`walk_sig_fields`]) gets one more row, signed and of unknown validity,
|
||||
/// for the fields not read: any of them may be signed, so the document still counts as signed.
|
||||
pub fn list(doc: &Document, bytes: &[u8], trust: &TrustStore) -> Vec<SignatureInfo> {
|
||||
list_cached(doc, bytes, trust, &DigestCache::default())
|
||||
}
|
||||
@@ -268,7 +433,8 @@ pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &Dig
|
||||
let pages = annot_pages(doc);
|
||||
let mut out = Vec::new();
|
||||
let mut field_values: HashSet<ObjRef> = HashSet::new();
|
||||
for f in sig_fields(doc) {
|
||||
let (fields, whole) = walk_sig_fields(doc);
|
||||
for f in fields {
|
||||
if let Some(v_ref) = f.dict.get(b"V").and_then(Object::as_ref) {
|
||||
field_values.insert(v_ref);
|
||||
}
|
||||
@@ -278,10 +444,7 @@ pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &Dig
|
||||
.and_then(|(_, w)| nums(doc, w, b"Rect"))
|
||||
.filter(|r| r.len() == 4)
|
||||
.map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]);
|
||||
// Shown when it has a box, and its widget isn't Hidden (2) or NoView (32), as the forms
|
||||
// crate reads a widget's flags.
|
||||
let flags = widget.and_then(|(_, w)| w.get(b"F")).and_then(|o| doc.resolve(o).as_int()).unwrap_or(0);
|
||||
let visible = rect.is_some_and(|r| r[2] - r[0] > 0.5 && r[3] - r[1] > 0.5) && flags & (2 | 32) == 0;
|
||||
let visible = widget.is_some_and(|(_, w)| widget_shown(doc, w));
|
||||
let v = f.signature(doc);
|
||||
let mut info = SignatureInfo {
|
||||
field: f.name.clone(),
|
||||
@@ -308,6 +471,7 @@ pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &Dig
|
||||
timestamp_time: None,
|
||||
status: Status::Unknown,
|
||||
modification: Modification::None,
|
||||
verdict_shown: false,
|
||||
details: Vec::new(),
|
||||
};
|
||||
if let Some(v) = v {
|
||||
@@ -351,14 +515,54 @@ pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &Dig
|
||||
timestamp_time: None,
|
||||
status: Status::Unknown,
|
||||
modification: Modification::None,
|
||||
verdict_shown: false,
|
||||
details: Vec::new(),
|
||||
};
|
||||
validate_doc_timestamp(doc, bytes, d, &mut info, cache, trust);
|
||||
out.push(info);
|
||||
}
|
||||
if !whole {
|
||||
out.push(unread_fields());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The row [`list_cached`] adds for the fields a walk cut short did not read ([`walk_sig_fields`]).
|
||||
/// Any of them may be signed, so the row is signed, of unknown validity: the document still counts
|
||||
/// as signed, and nothing rewrites it in full.
|
||||
fn unread_fields() -> SignatureInfo {
|
||||
SignatureInfo {
|
||||
field: "UnreadFields".to_string(),
|
||||
signed: true,
|
||||
page: None,
|
||||
rect: None,
|
||||
visible: false,
|
||||
signer: None,
|
||||
certificate: None,
|
||||
chain: Vec::new(),
|
||||
date: None,
|
||||
signing_time: None,
|
||||
reason: None,
|
||||
location: None,
|
||||
contact: None,
|
||||
sub_filter: None,
|
||||
doc_timestamp: false,
|
||||
certify: None,
|
||||
revision: 0,
|
||||
signed_len: 0,
|
||||
digest: None,
|
||||
algorithm: None,
|
||||
timestamp: false,
|
||||
timestamp_time: None,
|
||||
status: Status::Unknown,
|
||||
modification: Modification::None,
|
||||
verdict_shown: false,
|
||||
details: vec![Detail::plain(
|
||||
"This form has too many fields, or a field name too long, for PdfCraft to read them all. Any signature among the rest is not listed, so the document is treated as signed.",
|
||||
)],
|
||||
}
|
||||
}
|
||||
|
||||
fn page_index(doc: &Document, p: ObjRef) -> Option<usize> {
|
||||
pdfcraft_annot::page_refs(doc).ok()?.iter().position(|r| *r == p)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::listing::{mk_rotation, nums, sig_fields};
|
||||
use super::listing::{mk_rotation, nums, sig_fields, text, walk_sig_fields};
|
||||
use super::{Certificate, Dict, DigestAlg, DigitalId, Document, HashSet, Object, PdfString, SaveOptions, SignError, SignedData, Stream};
|
||||
|
||||
// ── signing ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -232,6 +232,10 @@ fn sign_inner(
|
||||
w.set(b"Subtype".to_vec(), Object::name("Widget"));
|
||||
w.set(b"FT".to_vec(), Object::name("Sig"));
|
||||
w.set(b"T".to_vec(), PdfString::text(&fname));
|
||||
// The walk lists no field without a usable name, and counts a signed one as unread.
|
||||
if text(&doc, &w, b"T").is_none() {
|
||||
return Err(SignError::Pdf("a signature field needs a name that isn't blank".into()));
|
||||
}
|
||||
w.set(b"V".to_vec(), Object::Ref(sig));
|
||||
w.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|x| Object::Real(*x)).collect()));
|
||||
w.set(b"P".to_vec(), Object::Ref(page));
|
||||
@@ -280,6 +284,14 @@ fn sign_inner(
|
||||
// Inline (or new) AcroForms stay in the catalog.
|
||||
None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?,
|
||||
}
|
||||
// The walk has to read the whole form, the new field included: past its caps the
|
||||
// signature would be listed as unread fields, and its name could repeat an unread one.
|
||||
if !walk_sig_fields(&doc).1 {
|
||||
return Err(SignError::Pdf(
|
||||
"this form has too many fields, or a field name too long, for PdfCraft to read them all, so it can't add a signature field"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
(wr, rect, turn)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ impl TrustStore {
|
||||
}
|
||||
|
||||
/// Acrobat's three verdicts.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Status {
|
||||
/// Intact, signed by a trusted identity, and any later changes are permitted.
|
||||
Valid,
|
||||
@@ -217,6 +217,8 @@ pub const WORDINGS: &[&str] = &[
|
||||
"The document has been altered since this timestamp was applied in ways it does not permit ({}).",
|
||||
"The timestamp token is valid and its authority is trusted.",
|
||||
"The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates.",
|
||||
// What the list itself could not read.
|
||||
"This form has too many fields, or a field name too long, for PdfCraft to read them all. Any signature among the rest is not listed, so the document is treated as signed.",
|
||||
// The one line that stands for all of it.
|
||||
"Unsigned signature field",
|
||||
"Signature is valid",
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use super::*;
|
||||
use pdfcraft_testkit::Pdf;
|
||||
|
||||
/// A document whose form lists `fields`, with `objects` numbered from 3.
|
||||
fn form(fields: &str, objects: &[String]) -> Document {
|
||||
let pdf = Pdf::new()
|
||||
.obj(format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [{fields}] >> >>"))
|
||||
.obj("<< /Type /Pages /Kids [] /Count 0 >>")
|
||||
.objs(objects);
|
||||
Document::open(Arc::new(pdf.build())).unwrap()
|
||||
}
|
||||
|
||||
/// Each name's first three characters and its length: enough to tell them apart without
|
||||
/// printing thousands of bytes when an assertion fails.
|
||||
fn short(fields: &[Field]) -> Vec<(String, usize)> {
|
||||
fields.iter().map(|f| (f.name.chars().take(3).collect(), f.name.len())).collect()
|
||||
}
|
||||
|
||||
/// A form as writers make them: names join down the tree, a field takes its type from the nearest
|
||||
/// field above that has one, and named signature fields are listed parents first. A field listed
|
||||
/// twice, or reached again from below, counts once, and a kid `/Fields` lists as well keeps its
|
||||
/// full name; a kid may be written in place.
|
||||
#[test]
|
||||
fn nested_fields_are_named_down_the_tree() {
|
||||
let doc = form(
|
||||
"3 0 R 6 0 R 9 0 R 11 0 R 3 0 R 4 0 R",
|
||||
&[
|
||||
"<< /T (parent) /FT /Sig /Kids [4 0 R 5 0 R] >>".into(),
|
||||
"<< /T (a) /Kids [12 0 R 3 0 R] >>".into(),
|
||||
"<< /T (b) /FT /Tx >>".into(),
|
||||
"<< /T (form) /Kids [7 0 R 8 0 R] >>".into(),
|
||||
"<< /T (sig) /FT /Sig /Type /Annot /Subtype /Widget /Rect [0 0 10 10] >>".into(),
|
||||
"<< /T (text) /FT /Tx >>".into(),
|
||||
"<< /T () /Kids [10 0 R] >>".into(),
|
||||
"<< /T (inner) /FT /Sig >>".into(),
|
||||
"<< /FT /Sig /Kids [<< /T (direct) >>] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Rect [1 2 3 4] >>".into(),
|
||||
],
|
||||
);
|
||||
let fields = sig_fields(&doc);
|
||||
let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["parent", "parent.a", "form.sig", "inner", "direct"]);
|
||||
let widgets: Vec<Vec<Option<ObjRef>>> = fields.iter().map(|f| f.widgets.iter().map(|(r, _)| *r).collect()).collect();
|
||||
assert_eq!(widgets, [vec![], vec![Some(ObjRef::new(12, 0))], vec![Some(ObjRef::new(7, 0))], vec![], vec![]]);
|
||||
assert!(walk_sig_fields(&doc).1, "a form within the caps is read whole");
|
||||
}
|
||||
|
||||
/// A widget two fields list stays listed under both, whether each lists it in its own `/Kids` or
|
||||
/// both list one shared array: the status layers count these claims to leave such a widget alone.
|
||||
#[test]
|
||||
fn a_widget_two_fields_list_is_listed_under_both() {
|
||||
let widget = || "<< /Type /Annot /Subtype /Widget /Rect [0 0 1 1] >>".to_string();
|
||||
let own = form("3 0 R 4 0 R", &["<< /T (a) /FT /Sig /Kids [5 0 R] >>".into(), "<< /T (b) /FT /Sig /Kids [5 0 R] >>".into(), widget()]);
|
||||
let shared =
|
||||
form("3 0 R 4 0 R", &["<< /T (a) /FT /Sig /Kids 6 0 R >>".into(), "<< /T (b) /FT /Sig /Kids 6 0 R >>".into(), widget(), "[5 0 R]".into()]);
|
||||
for (how, doc) in [("own arrays", own), ("one shared array", shared)] {
|
||||
let widgets: Vec<Vec<Option<ObjRef>>> = sig_fields(&doc).iter().map(|f| f.widgets.iter().map(|(r, _)| *r).collect()).collect();
|
||||
assert_eq!(widgets, [vec![Some(ObjRef::new(5, 0))], vec![Some(ObjRef::new(5, 0))]], "{how}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every kid's name starts with its parent's. Copied into each kid, a 1 MB name over 100,000 kids
|
||||
/// is 100 GB. A name over the cap is not read, and neither is anything under it.
|
||||
///
|
||||
/// The small form goes first: without the cap it fails on the names it lists, long before the
|
||||
/// large one could use up the machine's memory.
|
||||
#[test]
|
||||
fn a_field_named_over_the_cap_is_skipped_with_its_kids() {
|
||||
for (len, kids) in [(64 << 10, 200), (1 << 20, 100_000)] {
|
||||
let doc = form(
|
||||
"3 0 R 5 0 R",
|
||||
&[
|
||||
format!("<< /T 4 0 R /FT /Sig /Kids [{}] >>", "<< /T (x) >> ".repeat(kids)),
|
||||
format!("({})", "n".repeat(len)),
|
||||
"<< /T (ok) /FT /Sig >>".into(),
|
||||
],
|
||||
);
|
||||
let (fields, whole) = walk_sig_fields(&doc);
|
||||
let names: Vec<String> = fields.into_iter().map(|f| f.name).collect();
|
||||
let bytes: usize = names.iter().map(String::len).sum();
|
||||
assert!(names == ["ok"], "a {len}-byte name over {kids} kids: {} fields listed, {bytes} bytes of names", names.len());
|
||||
assert!(!whole, "the long name was passed over");
|
||||
}
|
||||
}
|
||||
|
||||
/// The cap is on the whole name as read, dots included. A partial name stored in more bytes than
|
||||
/// any name within the cap could take, even as UTF-16, is not read, whatever it trims to.
|
||||
#[test]
|
||||
fn names_up_to_the_cap_are_read_and_longer_ones_are_not() {
|
||||
let max = MAX_FIELD_NAME_BYTES;
|
||||
let utf16 = |n: usize| format!("<FEFF{}>", "0061".repeat(n));
|
||||
let doc = form(
|
||||
"3 0 R 4 0 R 5 0 R 8 0 R 9 0 R 10 0 R 11 0 R",
|
||||
&[
|
||||
format!("<< /T ({}) /FT /Sig >>", "a".repeat(max)),
|
||||
format!("<< /T ({}) /FT /Sig >>", "b".repeat(max + 1)),
|
||||
"<< /T (p) /FT /Sig /Kids [6 0 R 7 0 R] >>".into(),
|
||||
format!("<< /T ({}) >>", "c".repeat(max - 2)),
|
||||
format!("<< /T ({}) >>", "d".repeat(max - 1)),
|
||||
format!("<< /T {} /FT /Sig >>", utf16(max)),
|
||||
format!("<< /T {} /FT /Sig >>", utf16(max + 1)),
|
||||
format!("<< /T (x{}) /FT /Sig >>", " ".repeat(2 * max + 1)),
|
||||
format!("<< /T (y{}) /FT /Sig >>", " ".repeat(2 * max + 2)),
|
||||
],
|
||||
);
|
||||
let (fields, whole) = walk_sig_fields(&doc);
|
||||
let expected = [("aaa", max), ("p", 1), ("p.c", max), ("aaa", max), ("x", 1)].map(|(s, n)| (s.to_string(), n));
|
||||
assert_eq!(short(&fields), expected);
|
||||
assert!(!whole, "fields passed over for their names");
|
||||
}
|
||||
|
||||
/// Passed over for its name, a leaf of another type leaves the walk whole: it is no signature
|
||||
/// field and has no kids to hold one, so an unsigned form with a text field named past the cap
|
||||
/// stays unsigned. A field's own type comes before the one it takes from above.
|
||||
#[test]
|
||||
fn a_leaf_of_another_type_named_past_the_cap_leaves_the_walk_whole() {
|
||||
let long = "a".repeat(MAX_FIELD_NAME_BYTES + 1);
|
||||
let stored = format!("x{}", " ".repeat(2 * MAX_FIELD_NAME_BYTES + 2));
|
||||
for (what, field, whole) in [
|
||||
("a text field", format!("<< /T ({long}) /FT /Tx >>"), true),
|
||||
("a text field stored too long to read", format!("<< /T ({stored}) /FT /Tx >>"), true),
|
||||
("a field of no type", format!("<< /T ({long}) >>"), true),
|
||||
("a text field under a signature field", format!("<< /T (p) /FT /Sig /Kids [<< /T ({long}) /FT /Tx >>] >>"), true),
|
||||
("a signature field", format!("<< /T ({long}) /FT /Sig >>"), false),
|
||||
("a kid taking the signature type", format!("<< /T (p) /FT /Sig /Kids [<< /T ({long}) >>] >>"), false),
|
||||
("a text field with a signature kid", format!("<< /T ({long}) /FT /Tx /Kids [<< /T (k) /FT /Sig >>] >>"), false),
|
||||
] {
|
||||
assert_eq!(walk_sig_fields(&form("3 0 R", &[field])).1, whole, "{what}");
|
||||
}
|
||||
let doc = form("3 0 R", &[format!("<< /T ({long}) /FT /Tx >>")]);
|
||||
assert!(list(&doc, doc.bytes(), &TrustStore::default()).is_empty(), "no row for a field passed over");
|
||||
}
|
||||
|
||||
/// No more than `MAX_FIELDS` fields are looked at: the first in `/Fields`, then kids as they are
|
||||
/// reached, out of whatever room is left.
|
||||
#[test]
|
||||
fn the_walk_stops_after_max_fields() {
|
||||
let fields = |n: usize| (0..n).map(|i| format!("<< /T (f{i}) /FT /Sig >>")).collect::<String>();
|
||||
let (listed, whole) = walk_sig_fields(&form(&fields(MAX_FIELDS), &[]));
|
||||
assert_eq!((listed.len(), whole), (MAX_FIELDS, true));
|
||||
let (listed, whole) = walk_sig_fields(&form(&fields(MAX_FIELDS + 1), &[]));
|
||||
assert_eq!((listed.len(), whole), (MAX_FIELDS, false));
|
||||
assert_eq!(listed.last().map(|f| f.name.clone()), Some(format!("f{}", MAX_FIELDS - 1)));
|
||||
// The first field's kids take the room that is left, so the second field's kid is not reached.
|
||||
let kids = (0..MAX_FIELDS).map(|i| format!("<< /T (k{i}) >>")).collect::<String>();
|
||||
let (listed, whole) = walk_sig_fields(&form(
|
||||
"3 0 R 4 0 R",
|
||||
&[format!("<< /T (p) /FT /Sig /Kids [{kids}] >>"), "<< /T (q) /FT /Sig /Kids [<< /T (k) >>] >>".into()],
|
||||
));
|
||||
assert_eq!((listed.len(), whole), (MAX_FIELDS, false));
|
||||
assert_eq!(listed.last().map(|f| f.name.clone()), Some("q".to_string()));
|
||||
}
|
||||
|
||||
/// No more than `MAX_ENTRIES` entries of `/Fields` and the `/Kids` arrays are read, all together.
|
||||
#[test]
|
||||
fn the_walk_reads_no_more_than_max_entries() {
|
||||
// The field is one entry of /Fields; its widgets take the rest.
|
||||
let field = |widgets: usize| form(&format!("<< /T (p) /FT /Sig /Kids [{}] >>", "<< /Subtype /Widget >> ".repeat(widgets)), &[]);
|
||||
let (listed, whole) = walk_sig_fields(&field(MAX_ENTRIES - 1));
|
||||
assert_eq!((listed[0].widgets.len(), whole), (MAX_ENTRIES - 1, true));
|
||||
let (listed, whole) = walk_sig_fields(&field(MAX_ENTRIES));
|
||||
assert_eq!((listed[0].widgets.len(), whole), (MAX_ENTRIES - 1, false));
|
||||
}
|
||||
|
||||
/// A `/Kids` array shared by two fields on every level doubles the paths through the tree each
|
||||
/// level down: 2^41 fields for 40 levels. A field written in place in a shared array is visited
|
||||
/// once, under the first field that reaches it.
|
||||
#[test]
|
||||
fn a_kids_array_shared_by_many_fields_is_walked_once() {
|
||||
for levels in [16, 40] {
|
||||
let level = |i: usize| {
|
||||
if i + 1 < levels {
|
||||
format!("[<< /T (a) /Kids {n} 0 R >> << /T (b) /Kids {n} 0 R >>]", n = i + 4)
|
||||
} else {
|
||||
"[<< /T (a) >> << /T (b) >>]".to_string()
|
||||
}
|
||||
};
|
||||
let doc = form("<< /T (r) /FT /Sig /Kids 3 0 R >>", &(0..levels).map(level).collect::<Vec<_>>());
|
||||
assert_eq!(sig_fields(&doc).len(), 2 * levels + 1, "{levels} levels");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `/Fields` array that a field also lists as its `/Kids` leads back to itself, one name longer
|
||||
/// each time round. Each field in it is listed once.
|
||||
#[test]
|
||||
fn a_fields_array_listed_again_as_kids_is_walked_once() {
|
||||
let pdf = Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields 3 0 R >> >>")
|
||||
.obj("<< /Type /Pages /Kids [] /Count 0 >>")
|
||||
.obj("[<< /T (a) /FT /Sig /Kids 3 0 R >>]");
|
||||
let doc = Document::open(Arc::new(pdf.build())).unwrap();
|
||||
let names: Vec<String> = sig_fields(&doc).into_iter().map(|f| f.name).collect();
|
||||
assert_eq!(names, ["a"]);
|
||||
}
|
||||
|
||||
/// A `/Kids` array every field lists is read under each of them, which the entry cap bounds:
|
||||
/// 20,000 fields over one array of 1,000 widgets would be 20 million entries.
|
||||
///
|
||||
/// The small form goes first, as above.
|
||||
#[test]
|
||||
fn a_kids_array_many_fields_list_is_read_within_the_cap() {
|
||||
for (fields, widgets) in [(200, 1_000), (MAX_FIELDS, 1_000)] {
|
||||
let listing = (0..fields).map(|i| format!("<< /T (f{i}) /FT /Sig /Kids 3 0 R >>")).collect::<String>();
|
||||
let array = format!("[{}]", (0..widgets).map(|i| format!("{} 0 R ", i + 4)).collect::<String>());
|
||||
let objects: Vec<String> = std::iter::once(array).chain((0..widgets).map(|_| "<< /Subtype /Widget >>".to_string())).collect();
|
||||
let (listed, whole) = walk_sig_fields(&form(&listing, &objects));
|
||||
let read = listed.iter().map(|f| f.widgets.len()).sum::<usize>() + fields;
|
||||
assert!(read <= MAX_ENTRIES && !whole, "{fields} fields over {widgets} widgets: {read} entries read");
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget every field lists is listed under each as one copy: copied into each, one 1 MB widget
|
||||
/// under 20,000 fields is 20 GB.
|
||||
///
|
||||
/// The small form goes first, as above.
|
||||
#[test]
|
||||
fn a_widget_many_fields_list_is_copied_once() {
|
||||
for (len, fields) in [(64 << 10, 1_000), (1 << 20, MAX_FIELDS)] {
|
||||
let listing = (0..fields).map(|i| format!("<< /T (f{i}) /FT /Sig /Kids [3 0 R] >>")).collect::<String>();
|
||||
let doc = form(&listing, &[format!("<< /Type /Annot /Subtype /Widget /Rect [0 0 1 1] /Junk ({}) >>", "j".repeat(len))]);
|
||||
let listed = sig_fields(&doc);
|
||||
assert_eq!(listed.len(), fields);
|
||||
let first = &listed[0].widgets[0].1;
|
||||
let shared = listed.iter().all(|f| f.widgets.len() == 1 && Rc::ptr_eq(&f.widgets[0].1, first));
|
||||
assert!(shared, "a {len}-byte widget under {fields} fields");
|
||||
}
|
||||
}
|
||||
|
||||
/// So is a widget written in place in a `/Kids` array many fields list: its place in the array
|
||||
/// stands for it, as a reference would.
|
||||
#[test]
|
||||
fn a_widget_written_into_a_shared_kids_array_is_copied_once() {
|
||||
let listing = (0..1_000).map(|i| format!("<< /T (f{i}) /FT /Sig /Kids 3 0 R >>")).collect::<String>();
|
||||
let listed = sig_fields(&form(&listing, &["[<< /Type /Annot /Subtype /Widget /Rect [0 0 1 1] >>]".into()]));
|
||||
assert_eq!(listed.len(), 1_000);
|
||||
let first = &listed[0].widgets[0].1;
|
||||
assert!(listed.iter().all(|f| f.widgets.len() == 1 && Rc::ptr_eq(&f.widgets[0].1, first)), "an in-place widget is copied once");
|
||||
}
|
||||
|
||||
/// A one-page form listing the signature field `s`, then `text` text fields.
|
||||
fn form_with_text_fields(text: usize) -> Document {
|
||||
let fields: String = (0..text).map(|i| format!("<< /T (t{i}) /FT /Tx >>")).collect();
|
||||
let pdf = Pdf::new()
|
||||
.obj(format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R {fields}] >> >>"))
|
||||
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [4 0 R] >>")
|
||||
.obj("<< /Type /Annot /Subtype /Widget /FT /Sig /T (s) /Rect [10 10 110 60] /P 3 0 R >>");
|
||||
Document::open(Arc::new(pdf.build())).unwrap()
|
||||
}
|
||||
|
||||
/// The test digital ID, and options that sign a new field.
|
||||
fn signer() -> (DigitalId, SignOptions) {
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/ec-p256.p12")).unwrap();
|
||||
(crate::pkcs12::open(&p12, "test").unwrap(), SignOptions { date: "D:20261027120000Z".into(), ..SignOptions::default() })
|
||||
}
|
||||
|
||||
/// Signing adds a field only where the walk reads it back, the whole form with it: past a cap the
|
||||
/// new signature would be listed as unread fields, and its name could repeat an unread field's. A
|
||||
/// field the walk already reads is still signed in a form it can't read whole.
|
||||
#[test]
|
||||
fn a_new_signature_field_is_added_only_where_the_walk_reads_it_back() {
|
||||
let (id, opts) = signer();
|
||||
// With `s` before them, the new field is the last one the walk takes.
|
||||
let signed = sign(&form_with_text_fields(MAX_FIELDS - 2), &id, &opts).unwrap();
|
||||
let (fields, whole) = walk_sig_fields(&Document::open(Arc::new(signed)).unwrap());
|
||||
assert!(whole && fields.iter().any(|f| f.name == "Signature1"), "the new field is read back");
|
||||
let refused = sign(&form_with_text_fields(MAX_FIELDS - 1), &id, &opts);
|
||||
assert!(matches!(&refused, Err(SignError::Pdf(m)) if m.contains("too many fields")), "{:?}", refused.as_ref().err());
|
||||
let existing = SignOptions { field: Some("s".into()), ..opts };
|
||||
assert!(sign(&form_with_text_fields(MAX_FIELDS), &id, &existing).is_ok(), "a field the walk reads is signed as before");
|
||||
}
|
||||
|
||||
/// The walk lists no field without a name, so a new signature field named only with spaces or NULs
|
||||
/// would be a signature nothing shows: signing refuses it.
|
||||
#[test]
|
||||
fn a_new_signature_field_with_a_blank_name_is_refused() {
|
||||
let (id, opts) = signer();
|
||||
for name in ["", " ", "\0"] {
|
||||
let blank = SignOptions { new_field_name: Some(name.into()), ..opts.clone() };
|
||||
let refused = sign(&form_with_text_fields(0), &id, &blank);
|
||||
assert!(matches!(&refused, Err(SignError::Pdf(m)) if m.contains("blank")), "{name:?}: {:?}", refused.as_ref().err());
|
||||
}
|
||||
let named = SignOptions { new_field_name: Some(" Approval ".into()), ..opts };
|
||||
assert!(sign(&form_with_text_fields(0), &id, &named).is_ok(), "a name with spaces around it is read");
|
||||
}
|
||||
|
||||
/// Every kid's name repeats its parent's, so the names the walk keeps are capped all together: a
|
||||
/// long name over a few thousand kids stops the walk before it adds up.
|
||||
#[test]
|
||||
fn the_names_kept_stop_at_max_name_bytes() {
|
||||
let parent = "p".repeat(MAX_FIELD_NAME_BYTES - 8);
|
||||
let form_of = |kids: usize| {
|
||||
let kids = (0..kids).map(|i| format!("<< /T (k{i}) >>")).collect::<String>();
|
||||
form("3 0 R", &[format!("<< /T ({parent}) /FT /Sig /Kids [{kids}] >>")])
|
||||
};
|
||||
// Each kid's name is the parent's and a few bytes more, just under the per-name cap.
|
||||
let fits = MAX_NAME_BYTES / MAX_FIELD_NAME_BYTES - 1;
|
||||
let (listed, whole) = walk_sig_fields(&form_of(fits));
|
||||
assert_eq!((listed.len(), whole), (fits + 1, true));
|
||||
let (listed, whole) = walk_sig_fields(&form_of(2 * fits));
|
||||
let kept: usize = listed.iter().map(|f| f.name.len()).sum();
|
||||
assert!(!whole && kept <= MAX_NAME_BYTES, "{} fields, {kept} bytes of names", listed.len());
|
||||
}
|
||||
|
||||
/// A form whose only signature field, signed, is named one byte past the cap, and the file's
|
||||
/// bytes. `list` needs both.
|
||||
fn signed_field_past_the_cap() -> (Document, Vec<u8>) {
|
||||
let pdf = Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [3 0 R] >> >>")
|
||||
.obj("<< /Type /Pages /Kids [] /Count 0 >>")
|
||||
.obj(format!("<< /T ({}) /FT /Sig /V 4 0 R >>", "a".repeat(MAX_FIELD_NAME_BYTES + 1)))
|
||||
.obj("<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /ByteRange [0 1 2 1] /Contents <00> >>")
|
||||
.build();
|
||||
(Document::open(Arc::new(pdf.clone())).unwrap(), pdf)
|
||||
}
|
||||
|
||||
/// A field the walk did not read may be signed, so a walk cut short is listed as one more row,
|
||||
/// signed and of unknown validity: the document still counts as signed.
|
||||
#[test]
|
||||
fn a_walk_cut_short_is_listed_as_a_signed_row_of_unknown_validity() {
|
||||
let (doc, bytes) = signed_field_past_the_cap();
|
||||
let rows: Vec<_> = list(&doc, &bytes, &TrustStore::default()).into_iter().map(|r| (r.field, r.signed, r.status)).collect();
|
||||
assert_eq!(rows, [("UnreadFields".to_string(), true, Status::Unknown)]);
|
||||
}
|
||||
|
||||
/// The row says why it is there, in a sentence an interface can translate.
|
||||
#[test]
|
||||
fn the_row_for_a_walk_cut_short_says_why() {
|
||||
let (doc, bytes) = signed_field_past_the_cap();
|
||||
let rows = list(&doc, &bytes, &TrustStore::default());
|
||||
let said: Vec<&str> = rows.iter().flat_map(|r| &r.details).map(|d| d.message.as_str()).collect();
|
||||
assert!(matches!(said.as_slice(), [s] if s.starts_with("This form has too many fields")), "{said:?}");
|
||||
}
|
||||
|
||||
/// A walk that reads the whole form adds no such row: a name at the cap is read.
|
||||
#[test]
|
||||
fn a_whole_walk_adds_no_row_for_unread_fields() {
|
||||
let pdf = Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [3 0 R] >> >>")
|
||||
.obj("<< /Type /Pages /Kids [] /Count 0 >>")
|
||||
.obj(format!("<< /T ({}) /FT /Sig >>", "a".repeat(MAX_FIELD_NAME_BYTES)))
|
||||
.build();
|
||||
let doc = Document::open(Arc::new(pdf.clone())).unwrap();
|
||||
let rows: Vec<_> = list(&doc, &pdf, &TrustStore::default()).into_iter().map(|r| (r.field.len(), r.signed)).collect();
|
||||
assert_eq!(rows, [(MAX_FIELD_NAME_BYTES, false)]);
|
||||
}
|
||||
|
||||
/// A signed field with no name to be listed under, at the top or a kid whose `/T` is blank, cuts
|
||||
/// the walk short: its signature is there all the same. A nameless field that isn't signed hides
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn a_signed_field_without_a_name_cuts_the_walk_short() {
|
||||
let sig = "<< /Type /Sig >>".to_string();
|
||||
for (field, whole) in [
|
||||
("<< /FT /Sig /V 4 0 R >>", false),
|
||||
("<< /T ( ) /FT /Sig /V 4 0 R >>", false),
|
||||
("<< /T (p) /FT /Sig /Kids [<< /T () /V 4 0 R >>] >>", false),
|
||||
("<< /FT /Sig >>", true),
|
||||
("<< /FT /Sig /V null >>", true),
|
||||
] {
|
||||
assert_eq!(walk_sig_fields(&form("3 0 R", &[field.into(), sig.clone()])).1, whole, "{field}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A form that is there but of the wrong type, an `/AcroForm` that isn't a dictionary or a
|
||||
/// `/Fields` that isn't an array, may hold any field, signed ones too: the walk counts as cut
|
||||
/// short. A form that isn't there, or is null, holds none.
|
||||
#[test]
|
||||
fn a_form_of_the_wrong_type_cuts_the_walk_short() {
|
||||
for (form, whole) in [
|
||||
("/AcroForm [3 0 R]", false),
|
||||
("/AcroForm << /Fields 3 0 R >>", false),
|
||||
("/AcroForm << /Fields (3 0 R) >>", false),
|
||||
("/AcroForm << /Fields null >>", true),
|
||||
("/AcroForm << /Fields 9 0 R >>", true),
|
||||
("/AcroForm << >>", true),
|
||||
("/AcroForm null", true),
|
||||
("", true),
|
||||
] {
|
||||
let pdf = Pdf::new()
|
||||
.obj(format!("<< /Type /Catalog /Pages 2 0 R {form} >>"))
|
||||
.obj("<< /Type /Pages /Kids [] /Count 0 >>")
|
||||
.obj("<< /T (a) /FT /Sig >>")
|
||||
.build();
|
||||
assert_eq!(walk_sig_fields(&Document::open(Arc::new(pdf)).unwrap()).1, whole, "{form}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A kid with no /T is not always a widget. One with kids of its own is a field node, walked and
|
||||
/// adding nothing to the names below it, so the signed field under it is listed. A signature
|
||||
/// field and widget in one, unnamed under a field that isn't a signature field, cuts the walk
|
||||
/// short: by its type alone it may hold an inherited signature.
|
||||
#[test]
|
||||
fn signed_fields_behind_kids_without_a_name_are_not_passed_over() {
|
||||
let sig = "<< /Type /Sig >>".to_string();
|
||||
let nested =
|
||||
form("3 0 R", &["<< /T (p) /Kids [5 0 R] >>".into(), sig.clone(), "<< /Kids [6 0 R] >>".into(), "<< /T (s) /FT /Sig /V 4 0 R >>".into()]);
|
||||
let (listed, whole) = walk_sig_fields(&nested);
|
||||
let names: Vec<_> = listed.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(whole && names == ["p.s"], "{names:?}");
|
||||
for kid in ["<< /FT /Sig /V 4 0 R /Subtype /Widget >>", "<< /FT /Sig /Subtype /Widget >>", "<< /V 4 0 R /Subtype /Widget >>"] {
|
||||
let merged = form("3 0 R", &["<< /T (parent) /Kids [5 0 R] >>".into(), sig.clone(), kid.into()]);
|
||||
assert!(!walk_sig_fields(&merged).1, "{kid}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A signature field's own widgets read whole whatever they repeat of it, its type or its very
|
||||
/// signature; a widget signed apart from its field cuts the walk short. A text field's widgets
|
||||
/// hide nothing.
|
||||
#[test]
|
||||
fn a_fields_widgets_read_whole_unless_signed_apart() {
|
||||
let sig = "<< /Type /Sig >>".to_string();
|
||||
for (widget, whole) in
|
||||
[("<< /FT /Sig /Subtype /Widget >>", true), ("<< /V 4 0 R /Subtype /Widget >>", true), ("<< /V 6 0 R /Subtype /Widget >>", false)]
|
||||
{
|
||||
let doc = form("3 0 R", &["<< /T (Sig1) /FT /Sig /V 4 0 R /Kids [5 0 R] >>".into(), sig.clone(), widget.into(), sig.clone()]);
|
||||
let (listed, w) = walk_sig_fields(&doc);
|
||||
assert_eq!((w, listed.len(), listed.first().map(|f| f.widgets.len())), (whole, 1, Some(1)), "{widget}");
|
||||
}
|
||||
let text = form(
|
||||
"3 0 R",
|
||||
&["<< /T (name) /FT /Tx /V (Ada) /Kids [4 0 R 5 0 R] >>".into(), "<< /Subtype /Widget >>".into(), "<< /Subtype /Widget >>".into()],
|
||||
);
|
||||
assert!(walk_sig_fields(&text).1);
|
||||
}
|
||||
|
||||
/// One rule decides whether a signature widget is on its page, for the badge and for the redrawn
|
||||
/// layers alike: a box wider and taller than half a point, in either corner order, of finite
|
||||
/// numbers, on a widget that isn't Hidden or NoView.
|
||||
#[test]
|
||||
fn a_widget_shows_with_a_box_over_half_a_point_unless_hidden_or_noview() {
|
||||
let doc = form("", &[]);
|
||||
let widget = |s: &str| pdfcraft_cos::Lexer::new(s.as_bytes(), 0).object().unwrap().as_dict().unwrap().clone();
|
||||
let shown = |s: &str| widget_shown(&doc, &widget(s));
|
||||
assert!(shown("<< /Rect [100 100 300 220] >>"));
|
||||
assert!(shown("<< /Rect [300 220 100 100] >>"), "corners in either order");
|
||||
assert!(shown("<< /Rect [0 0 0.6 0.6] /F 4 >>"), "Print alone hides nothing");
|
||||
assert!(!shown("<< /Rect [100 100 300 220] /F 2 >>"), "Hidden");
|
||||
assert!(!shown("<< /Rect [100 100 300 220] /F 36 >>"), "NoView");
|
||||
assert!(!shown("<< /Rect [0 0 0.5 10] >>"), "half a point wide is no box");
|
||||
assert!(!shown("<< /Rect [0 0 10] >>"));
|
||||
assert!(!shown("<< /F 4 >>"));
|
||||
let mut endless = widget("<< >>");
|
||||
endless.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Real(f64::INFINITY), Object::Int(10)]));
|
||||
assert!(!widget_shown(&doc, &endless), "a box needs finite corners");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -66,6 +66,7 @@ impl PdfCraftApp {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
session: Session::new(),
|
||||
status_captions_lang: None,
|
||||
views: Vec::new(),
|
||||
closed_tabs: Vec::new(),
|
||||
active: None,
|
||||
@@ -462,6 +463,7 @@ impl eframe::App for PdfCraftApp {
|
||||
// Notices raised outside `ui` (opened files, OS events, the control channel) translate too.
|
||||
let lang = i18n::Lang::from_pref(&self.language);
|
||||
i18n::set_current(lang);
|
||||
self.apply_status_captions(lang);
|
||||
// Form date fields name months in the date language (Preferences ▸ Date format), which
|
||||
// follows the interface language until one is picked (#5303).
|
||||
pdfcraft_engine::dates::use_form_date_language(self.session.date_language().unwrap_or(lang.code()));
|
||||
|
||||
@@ -78,6 +78,9 @@ impl PdfCraftApp {
|
||||
}
|
||||
|
||||
pub(super) fn open_bytes_with_focus(&mut self, name: &str, path: Option<String>, bytes: Vec<u8>, activate: bool) -> Result<(), String> {
|
||||
// Files opened before the first frame (the ones named at startup) are drawn once, in the
|
||||
// interface language, rather than in English and again when the frame applies it.
|
||||
self.apply_status_captions(i18n::Lang::from_pref(&self.language));
|
||||
// Images and text files become new, unsaved PDFs (Create a PDF).
|
||||
let active = self.active;
|
||||
if let Some(r) = self.open_converted(name, &bytes) {
|
||||
|
||||
@@ -418,6 +418,63 @@ pub struct Snapshot {
|
||||
pub from: String,
|
||||
}
|
||||
|
||||
/// Whether what the pages show of the signatures differs between `before` and `after`: a verdict,
|
||||
/// or whether a signature's appearance draws it.
|
||||
pub(crate) fn signature_states_changed(before: &[pdfcraft_engine::SignatureInfo], after: &[pdfcraft_engine::SignatureInfo]) -> bool {
|
||||
before.len() != after.len()
|
||||
|| before.iter().zip(after).any(|(b, a)| b.field != a.field || b.status != a.status || b.verdict_shown != a.verdict_shown)
|
||||
}
|
||||
|
||||
/// The mark's side as a share of the widget's shorter side.
|
||||
const MARK_SIDE_SHARE: f32 = 0.25;
|
||||
|
||||
/// The largest side of the mark, in screen points, so zooming in stops enlarging it.
|
||||
const MARK_SIDE_MAX: f32 = 48.0;
|
||||
|
||||
/// The smallest legible side of the mark, in screen points: a widget whose shorter side is under
|
||||
/// `MARK_SIDE_MIN / MARK_SIDE_SHARE` (32 points) keeps only its outline. A field over that at 100%
|
||||
/// can fall under it when the page is zoomed out to fit.
|
||||
const MARK_SIDE_MIN: f32 = 8.0;
|
||||
|
||||
/// The gap between the mark and the widget's edges, as a share of the mark's side.
|
||||
const MARK_INSET_SHARE: f32 = 0.2;
|
||||
|
||||
/// The icon's size as a share of the mark's side; the icon is centred in it.
|
||||
const MARK_ICON_SHARE: f32 = 0.64;
|
||||
|
||||
/// The screen position of a widget's bottom-right corner on the page (`rect`, user space): where
|
||||
/// its validation mark goes, wherever the page's /Rotate and the view turn it.
|
||||
fn mark_anchor(xf: &PageXform, info: &DocInfo, page: usize, rect: [f64; 4]) -> Pos2 {
|
||||
let [_, bottom, right, _] = rect.map(|v| v as f32);
|
||||
xf.user_rect(info, page, [right, bottom, right, bottom]).min
|
||||
}
|
||||
|
||||
/// Where the validation mark sits in the part of a signature widget's screen rectangle that is on
|
||||
/// the page (`page`, on screen too), or `None` when that part is too small for a legible one. It
|
||||
/// goes in the corner nearest `anchor`, the screen position of the widget's bottom-right corner on
|
||||
/// the page, so it turns with the page and the view: off the middle, where a signer's artwork
|
||||
/// usually is, and clear of the state a named-layer appearance captions across its top.
|
||||
fn verdict_mark_rect(widget: Rect, page: Rect, anchor: Pos2) -> Option<Rect> {
|
||||
// A NaN or infinite corner has no place on screen.
|
||||
if !widget.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// A widget past the crop box's edge shows its mark on the page, not beside it.
|
||||
let widget = widget.intersect(page);
|
||||
let side = (widget.width().min(widget.height()) * MARK_SIDE_SHARE).min(MARK_SIDE_MAX);
|
||||
// Also refuses the negative side of an inverted rectangle.
|
||||
if !(MARK_SIDE_MIN..).contains(&side) {
|
||||
return None;
|
||||
}
|
||||
let inset = side * MARK_INSET_SHARE;
|
||||
// A NaN anchor compares false either way and takes the bottom-right corner.
|
||||
let left = anchor.x - widget.min.x < widget.max.x - anchor.x;
|
||||
let top = anchor.y - widget.min.y < widget.max.y - anchor.y;
|
||||
let x = if left { widget.min.x + inset } else { widget.max.x - inset - side };
|
||||
let y = if top { widget.min.y + inset } else { widget.max.y - inset - side };
|
||||
Some(Rect::from_min_size(pos2(x, y), vec2(side, side)))
|
||||
}
|
||||
|
||||
/// The name shown for a document tab, the window title and the Combine picker.
|
||||
/// A snapshot reads like its source document, plus the snapshot suffix that tells
|
||||
/// the tabs apart. Without a title the file name (which already carries the suffix)
|
||||
|
||||
@@ -13,6 +13,9 @@ pub(crate) struct SignatureBadge {
|
||||
pub(crate) fn signature_badges(signatures: &[pdfcraft_engine::SignatureInfo], page: usize) -> Vec<SignatureBadge> {
|
||||
signatures
|
||||
.iter()
|
||||
// A signature whose appearance draws its state keeps the badge too: the document controls
|
||||
// its own layers and can clip the drawn state away or cover it with its own artwork,
|
||||
// which it cannot do to the badge.
|
||||
.filter(|s| s.signed && s.visible && s.page == Some(page))
|
||||
.filter_map(|s| {
|
||||
s.rect.filter(|r| r.iter().all(|v| v.is_finite() && v.abs() < f32::MAX as f64)).map(|rect| SignatureBadge {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
//! Page input: what the tools do with the pointer on one page, what lies under it for the page
|
||||
//! router, the text layer, and what is drawn over the page after its text.
|
||||
|
||||
use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2};
|
||||
use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};
|
||||
use pdfcraft_render::{DocInfo, PageText};
|
||||
|
||||
use super::{DocView, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, signature_badges};
|
||||
use super::{
|
||||
DocView, MARK_ICON_SHARE, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, mark_anchor,
|
||||
signature_badges, verdict_mark_rect,
|
||||
};
|
||||
use crate::page_cursor::{Layer, Subject};
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::page_router::{Owner, PagePress, top};
|
||||
@@ -821,7 +824,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P
|
||||
unobstructed,
|
||||
..
|
||||
} = frame.env;
|
||||
let PageSlot { i, xf, .. } = *slot;
|
||||
let PageSlot { i, r, xf } = *slot;
|
||||
let PageFrame { resp, ptr, pointer, .. } = *frame;
|
||||
let painter = ui.painter();
|
||||
let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info };
|
||||
@@ -923,27 +926,29 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P
|
||||
view.flash = None;
|
||||
}
|
||||
}
|
||||
// Validation is viewer chrome over the signer's original artwork, which
|
||||
// may still say "Signature Not Verified". These badges never enter document
|
||||
// streams, image/text exports, vector printing or Print as Image.
|
||||
// Validation badges are viewer chrome over the widget. A recognised named-layer appearance may
|
||||
// show the verdict in the display copy too; saves retain its original streams. Badges never
|
||||
// enter document streams, exports or either print path. Their tips show where the comments'
|
||||
// do (`comments::tips_shown`).
|
||||
let tips = comments::tips_shown(tool, view);
|
||||
for badge in signature_badges(&doc.signatures, i) {
|
||||
let [x0, y0, x1, y1] = badge.rect;
|
||||
let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]);
|
||||
let (icon, color) = crate::sign_ui::signature_status_style(badge.status);
|
||||
painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside);
|
||||
// A widget too small for the mark keeps its outline: the verdict still shows.
|
||||
if sr.width() < 4.0 || sr.height() < 4.0 {
|
||||
// The page transform can turn a finite widget into one the screen cannot place; stroking
|
||||
// it would put NaN into the mesh.
|
||||
if !sr.is_finite() {
|
||||
continue;
|
||||
}
|
||||
// Big enough to read at a glance, small enough to leave the signer's own
|
||||
// text visible around it.
|
||||
let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0);
|
||||
let mark = Rect::from_center_size(sr.center(), vec2(side, side));
|
||||
icons::paint(ui, mark, icon, side * 0.64, color);
|
||||
// Overlapping widgets paint in order, so the topmost badge's verdict is the tip, with
|
||||
// any tool; a link's or a comment's tip wins over any badge.
|
||||
if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) {
|
||||
view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string());
|
||||
let (icon, color) = crate::sign_ui::signature_status_style(badge.status);
|
||||
painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside);
|
||||
if let Some(mark) = verdict_mark_rect(sr, r, mark_anchor(&xf, info, i, badge.rect)) {
|
||||
icons::paint(ui, mark, icon, mark.width() * MARK_ICON_SHARE, color);
|
||||
}
|
||||
// The whole widget answers the pointer, so a field too small for a mark still says its
|
||||
// verdict; only where it is on the page, though. Overlapping widgets paint in order, so
|
||||
// the topmost badge's verdict is the tip; a link's or a comment's tip wins over any badge.
|
||||
if tips && pointer.is_some_and(|p| sr.expand(2.0).intersect(r).contains(p)) {
|
||||
view.cursor.describe(Subject::SignatureBadge, crate::i18n::t(badge.summary).to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +83,27 @@ fn signature(signed: bool, visible: bool, page: Option<usize>, rect: Option<[f64
|
||||
timestamp_time: None,
|
||||
status: pdfcraft_engine::SignatureStatus::Valid,
|
||||
modification: pdfcraft_engine::sign::Modification::None,
|
||||
verdict_shown: false,
|
||||
details: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_states_change_with_a_verdict_or_what_draws_it() {
|
||||
let shown = signature(true, true, Some(0), Some([10.0, 10.0, 100.0, 50.0]));
|
||||
let before = vec![shown.clone()];
|
||||
assert!(!signature_states_changed(&before, &before), "identical lists report no change");
|
||||
let mut invalid = shown.clone();
|
||||
invalid.status = pdfcraft_engine::SignatureStatus::Invalid;
|
||||
let mut drawn = shown.clone();
|
||||
drawn.verdict_shown = true;
|
||||
let mut other = shown.clone();
|
||||
other.field = "Signature2".into();
|
||||
for (after, why) in [(vec![invalid], "verdict"), (vec![drawn], "drawn"), (vec![other], "field"), (Vec::new(), "count")] {
|
||||
assert!(signature_states_changed(&before, &after), "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_badges_keep_signed_visible_widgets_on_the_page() {
|
||||
let rect = [10.0, 10.0, 100.0, 50.0];
|
||||
@@ -125,6 +142,132 @@ fn signature_badges_skip_rectangles_that_cannot_reach_the_screen() {
|
||||
assert_eq!(badges.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_badges_keep_every_verdict_the_appearance_shows() {
|
||||
// Whether the redrawn layers are visible (not clipped or painted over) is not known, so a
|
||||
// valid verdict keeps its badge as well.
|
||||
use pdfcraft_engine::SignatureStatus::{Invalid, Unknown, Valid};
|
||||
for status in [Valid, Unknown, Invalid] {
|
||||
let mut shown = signature(true, true, Some(0), Some([10.0, 10.0, 100.0, 50.0]));
|
||||
(shown.verdict_shown, shown.status) = (true, status);
|
||||
assert_eq!(signature_badges(&[shown.clone()], 0).len(), 1, "{status:?}");
|
||||
shown.verdict_shown = false;
|
||||
assert_eq!(signature_badges(&[shown], 0).len(), 1, "{status:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_verdict_mark_takes_the_corner_nearest_its_anchor() {
|
||||
let widget = Rect::from_min_max(pos2(40.0, 60.0), pos2(540.0, 760.0));
|
||||
let centre = widget.center();
|
||||
for anchor in [widget.left_top(), widget.right_top(), widget.right_bottom(), widget.left_bottom()] {
|
||||
let mark = verdict_mark_rect(widget, Rect::EVERYTHING, anchor).expect("a widget this size carries a mark");
|
||||
assert!(widget.contains_rect(mark), "{mark:?} in {widget:?}");
|
||||
assert!(!mark.contains(centre), "the mark covers the artwork it annotates: {mark:?}");
|
||||
let (right, below) = (mark.center().x > centre.x, mark.center().y > centre.y);
|
||||
assert_eq!((right, below), (anchor.x > centre.x, anchor.y > centre.y), "{mark:?} for {anchor:?}");
|
||||
}
|
||||
// Without a usable anchor it sits bottom-right.
|
||||
let mark = verdict_mark_rect(widget, Rect::EVERYTHING, pos2(f32::NAN, f32::NAN)).unwrap();
|
||||
assert!(mark.min.x > centre.x && mark.min.y > centre.y, "{mark:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_verdict_mark_turns_with_the_page_and_the_view() {
|
||||
// A named-layer caption runs across the widget's top on the page; the mark takes the
|
||||
// opposite corner however the page's /Rotate and the view turn the two.
|
||||
let rect = [100.0, 100.0, 300.0, 220.0];
|
||||
for page_turn in [0_u16, 90, 180, 270] {
|
||||
for view_turn in [0_u16, 90, 180, 270] {
|
||||
let page =
|
||||
pdfcraft_render::PageInfo { width: 612.0, height: 792.0, label: "1".into(), crop: [0.0, 0.0, 612.0, 792.0], rotation: page_turn };
|
||||
let info = DocInfo { pages: vec![page], ..Default::default() };
|
||||
let xf = PageXform { rect: Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 800.0)), rot: view_turn, pw: 612.0, ph: 792.0 };
|
||||
let widget = xf.user_rect(&info, 0, rect.map(|v: f64| v as f32));
|
||||
let mark = verdict_mark_rect(widget, xf.rect, mark_anchor(&xf, &info, 0, rect)).expect("a widget this size carries a mark");
|
||||
let (right, below) = (mark.center().x > widget.center().x, mark.center().y > widget.center().y);
|
||||
let expected = match (page_turn + view_turn) % 360 {
|
||||
0 => (true, true),
|
||||
90 => (false, true),
|
||||
180 => (false, false),
|
||||
_ => (true, false),
|
||||
};
|
||||
assert_eq!((right, below), expected, "page /Rotate {page_turn}, view {view_turn}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_verdict_mark_scales_with_the_widget_until_the_cap() {
|
||||
let side_of = |shorter: f32| {
|
||||
let widget = Rect::from_min_max(pos2(0.0, 0.0), pos2(shorter, shorter * 2.0));
|
||||
verdict_mark_rect(widget, Rect::EVERYTHING, widget.right_bottom()).expect("a widget this size carries a mark").width()
|
||||
};
|
||||
// Literal values, not the constants, so a change to the share or the cap fails here.
|
||||
assert!((side_of(96.0) - 24.0).abs() < 1e-3, "under the cap the mark is a quarter of the widget");
|
||||
assert!((side_of(384.0) - 48.0).abs() <= 1e-3, "the mark stops at the 48pt the documentation promises");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legible_floor_is_the_exact_shortest_side_that_still_holds_a_mark() {
|
||||
// The sweep below skips widgets without a mark, so the floor is pinned from both sides
|
||||
// here.
|
||||
let at_floor = MARK_SIDE_MIN / MARK_SIDE_SHARE;
|
||||
let widget = |shorter: f32| Rect::from_min_max(pos2(10.0, 10.0), pos2(10.0 + shorter, 70.0));
|
||||
let mark =
|
||||
verdict_mark_rect(widget(at_floor), Rect::EVERYTHING, widget(at_floor).right_bottom()).expect("a widget exactly at the floor holds a mark");
|
||||
assert!((mark.width() - MARK_SIDE_MIN).abs() < 1e-3, "the mark at the floor is the floor, not less: {mark:?}");
|
||||
assert_eq!(verdict_mark_rect(widget(at_floor - 0.5), Rect::EVERYTHING, pos2(0.0, 0.0)), None, "a notch under the floor is not legible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_verdict_mark_is_none_for_a_rectangle_the_screen_cannot_hold() {
|
||||
// Each of these leaves the shorter side finite (`f32::min` returns the other operand for
|
||||
// NaN), so only the `is_finite` check refuses them; one per coordinate.
|
||||
let unplaceable = [
|
||||
Rect { min: pos2(f32::NAN, 0.0), max: pos2(400.0, 200.0) },
|
||||
Rect { min: pos2(0.0, 0.0), max: pos2(40.0, f32::NAN) },
|
||||
Rect { min: pos2(0.0, f32::NEG_INFINITY), max: pos2(40.0, 100.0) },
|
||||
Rect { min: pos2(0.0, 0.0), max: pos2(f32::INFINITY, 100.0) },
|
||||
];
|
||||
for widget in unplaceable {
|
||||
assert_eq!(verdict_mark_rect(widget, Rect::EVERYTHING, pos2(0.0, 0.0)), None, "{widget:?} has no place to sit at");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_verdict_mark_stays_inside_its_widget_at_every_finite_size() {
|
||||
// 50 and 56 are where a mark always taking the 48pt cap would no longer fit.
|
||||
let sizes = [0.5, 3.0, 6.0, 12.0, 20.0, 32.0, 40.0, 50.0, 56.0, 90.0, 200.0, 500.0, 5_000.0];
|
||||
for &w in &sizes {
|
||||
for &h in &sizes {
|
||||
let widget = Rect::from_min_max(pos2(10.0, 10.0), pos2(10.0 + w, 10.0 + h));
|
||||
for anchor in [widget.left_top(), widget.right_bottom()] {
|
||||
let Some(mark) = verdict_mark_rect(widget, Rect::EVERYTHING, anchor) else { continue };
|
||||
assert!(widget.contains_rect(mark), "{mark:?} is not inside {widget:?} for a {w}x{h} widget");
|
||||
}
|
||||
let Some(mark) = verdict_mark_rect(widget, Rect::EVERYTHING, widget.right_bottom()) else { continue };
|
||||
// A quarter of the shorter side each way: at most 6.25% of a square widget.
|
||||
let share = mark.width() * mark.height() / (widget.width() * widget.height());
|
||||
assert!(share <= MARK_SIDE_SHARE * MARK_SIDE_SHARE * 1.0001, "the mark covers {:.2}% of a {w}x{h} widget", share * 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_verdict_mark_stays_on_the_page_when_its_widget_crosses_the_edge() {
|
||||
let page = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0));
|
||||
let widget = Rect::from_min_max(pos2(200.0, 150.0), pos2(600.0, 450.0));
|
||||
let mark = verdict_mark_rect(widget, page, widget.right_bottom()).expect("the part on the page holds a mark");
|
||||
assert!(page.contains_rect(mark), "{mark:?} is off {page:?}");
|
||||
assert!(mark.max.x > 350.0 && mark.max.y > 250.0, "the corner of the part on the page nearest the anchor: {mark:?}");
|
||||
// Wholly off the page, or a sliver on it too thin for a legible mark: none.
|
||||
let off = widget.translate(vec2(400.0, 0.0));
|
||||
assert_eq!(verdict_mark_rect(off, page, off.right_bottom()), None);
|
||||
let sliver = Rect::from_min_max(pos2(390.0, 0.0), pos2(600.0, 300.0));
|
||||
assert_eq!(verdict_mark_rect(sliver, page, sliver.right_bottom()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_zoom_reads_the_side_the_page_width_is_on() {
|
||||
// A 200 x 100 pt page at 2 pixels per point: 400 x 200 on screen, or 200 x 400 turned.
|
||||
|
||||
@@ -154,10 +154,10 @@ impl PdfCraftApp {
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let (form_before, page_count_before, had_xfa) = if let Some(d) = self.session.get(id) {
|
||||
(Some(std::sync::Arc::clone(&d.form)), d.info.pages.len(), d.xfa.is_some())
|
||||
let (form_before, page_count_before, had_xfa, signatures_before) = if let Some(d) = self.session.get(id) {
|
||||
(Some(std::sync::Arc::clone(&d.form)), d.info.pages.len(), d.xfa.is_some(), Some(std::sync::Arc::clone(&d.signatures)))
|
||||
} else {
|
||||
(None, 0, false)
|
||||
(None, 0, false, None)
|
||||
};
|
||||
// A deletion moves the comments after it on the page: these say where the selected one,
|
||||
// and the one open in the panel's editor, went.
|
||||
@@ -190,16 +190,29 @@ impl PdfCraftApp {
|
||||
view.signature_drag.committed(&edit, doc.edit_generation());
|
||||
view.move_background.committed(&edit, doc.edit_generation());
|
||||
view.text_background.committed(&edit);
|
||||
if let Some(page) = comment_page(&edit).or_else(|| content_page(&edit)) {
|
||||
// A form edit that leaves the pages and XFA as they were: the pages its fields are
|
||||
// on, and the values that show over them until those pages' rasters have them.
|
||||
let form_edit = form_before
|
||||
.filter(|_| doc.info.pages.len() == page_count_before && doc.xfa.is_some() == had_xfa && doc.xfa.is_none() && is_form_edit(&edit))
|
||||
.map(|before| form_edit_pages(&before, &doc.form));
|
||||
let one_page = comment_page(&edit).or_else(|| content_page(&edit));
|
||||
if signatures_before.is_some_and(|before| crate::canvas::signature_states_changed(&before, &doc.signatures)) {
|
||||
// A signature shows its state on its own page, which an edit elsewhere can
|
||||
// change (a certification forbidding it, say): every page redraws. An edit that
|
||||
// moves no page (a form edit, or one to a page's comments or content) keeps the
|
||||
// values typed, in it and before it, showing until their pages redraw.
|
||||
let waiting = (form_edit.is_some() || one_page.is_some()).then(|| std::mem::take(&mut view.forms.unbaked));
|
||||
view.document_changed(info);
|
||||
if let Some(waiting) = waiting {
|
||||
view.forms.unbaked = waiting;
|
||||
}
|
||||
if let Some((_, unbaked)) = form_edit {
|
||||
view.forms.unbaked.extend(unbaked);
|
||||
}
|
||||
} else if let Some(page) = one_page {
|
||||
// Comment and content edits change one page: keep every other raster.
|
||||
view.page_changed(page);
|
||||
} else if let Some(form_before) = form_before
|
||||
&& doc.info.pages.len() == page_count_before
|
||||
&& doc.xfa.is_some() == had_xfa
|
||||
&& doc.xfa.is_none()
|
||||
&& is_form_edit(&edit)
|
||||
{
|
||||
let (changed_pages, unbaked) = form_edit_pages(&form_before, &doc.form);
|
||||
} else if let Some((changed_pages, unbaked)) = form_edit {
|
||||
view.forms.unbaked.extend(unbaked);
|
||||
for page in changed_pages {
|
||||
view.page_changed(page);
|
||||
|
||||
@@ -262,6 +262,8 @@ pub struct PdfCraftApp {
|
||||
/// Tools side and pane widths; which panes are open is `left_open` and `right`.
|
||||
pub side_panels: side_panels::SidePanels,
|
||||
pub session: Session,
|
||||
/// The interface language signature status captions were last drawn in.
|
||||
pub(crate) status_captions_lang: Option<i18n::Lang>,
|
||||
pub views: Vec<DocView>,
|
||||
pub(crate) closed_tabs: Vec<String>,
|
||||
/// `None` shows the Home tab.
|
||||
|
||||
@@ -37,7 +37,7 @@ pub(crate) use geometry::{corner_ratio, page_rotation, resized};
|
||||
pub use geometry::{drawn_text_box_rect, snug_text_box_rect, text_box_rect};
|
||||
#[cfg(test)]
|
||||
use input::hover_tip;
|
||||
pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input};
|
||||
pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input, tips_shown};
|
||||
pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid};
|
||||
pub(crate) use menu::{MenuRights, attachment_items, border_colour_button, context_menu, quick_bar_colour, quick_bar_controls};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -173,13 +173,18 @@ pub(crate) fn context(cx: &PageCx<'_>, index: Option<usize>, view: &mut DocView)
|
||||
view.comments.selected = index.map(|i| (cx.page, i));
|
||||
}
|
||||
|
||||
/// Whether the page shows a tip for what lies under the pointer (a comment, a signature's badge)
|
||||
/// with `tool`: not with Fill & Sign, whose placement stays clear; not while drawing freehand,
|
||||
/// where it would cover the next stroke (#429); and not while a comment is moved, resized or drawn.
|
||||
pub(crate) fn tips_shown(tool: QuickTool, view: &DocView) -> bool {
|
||||
let freehand = matches!(tool, QuickTool::Comment(CommentTool::Ink | CommentTool::FreehandHighlight | CommentTool::Eraser));
|
||||
!(matches!(tool, QuickTool::Fill(_)) || freehand || view.comments.gesture.is_some())
|
||||
}
|
||||
|
||||
/// The tip for the comment under the pointer, with any tool, beside that tool's own cursor: Acrobat
|
||||
/// shows a comment's tooltip whatever tool is active. Not with Fill & Sign, whose placement stays
|
||||
/// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a
|
||||
/// comment is moved, resized or drawn.
|
||||
/// shows a comment's tooltip whatever tool is active. Not where [`tips_shown`] says no.
|
||||
pub(crate) fn describe(cx: &PageCx<'_>, view: &mut DocView) {
|
||||
let freehand = matches!(cx.tool, QuickTool::Comment(CommentTool::Ink | CommentTool::FreehandHighlight | CommentTool::Eraser));
|
||||
if matches!(cx.tool, QuickTool::Fill(_)) || freehand || view.comments.gesture.is_some() {
|
||||
if !tips_shown(cx.tool, view) {
|
||||
return;
|
||||
}
|
||||
let Some(p) = cx.ptr.pos else { return };
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::path::PathBuf;
|
||||
|
||||
use egui::{Align, Color32, CornerRadius, Layout, Pos2, Rect, Stroke, vec2};
|
||||
use pdfcraft_engine::sign::{self, Appearance, AppearanceLabels, Certificate, DigitalId, Name, PrivateKey};
|
||||
use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatus};
|
||||
use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatus, StatusCaptions};
|
||||
|
||||
use crate::canvas::{DocView, PageToolContext, Snapshot, SnapshotKind};
|
||||
use crate::fill_sign::SavedSig;
|
||||
@@ -22,7 +22,7 @@ mod panel;
|
||||
#[cfg(test)]
|
||||
use dialog::detail_lines;
|
||||
use dialog::graphic_of;
|
||||
pub(crate) use dialog::{appearance_labels, dialog};
|
||||
pub(crate) use dialog::{appearance_labels, dialog, status_captions};
|
||||
pub(crate) use panel::{CertAction, banner, cert_viewer, panel, signature_status_style};
|
||||
pub use panel::{CertTab, CertViewer, PanelAction, certificate_pem};
|
||||
|
||||
@@ -571,6 +571,29 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw signature status captions in `lang` ([`status_captions`]) and redraw the pages, once
|
||||
/// per language change: the session rebuilds the documents whose captions it draws.
|
||||
pub(crate) fn apply_status_captions(&mut self, lang: crate::i18n::Lang) {
|
||||
if self.status_captions_lang == Some(lang) {
|
||||
return;
|
||||
}
|
||||
self.status_captions_lang = Some(lang);
|
||||
// Only a document that draws a signature state shows the words: the others keep their
|
||||
// pages, selection and search results.
|
||||
let drawing: Vec<pdfcraft_engine::DocId> = self
|
||||
.views
|
||||
.iter()
|
||||
.map(|v| v.id)
|
||||
.filter(|id| self.session.get(*id).is_some_and(|d| d.signatures.iter().any(|s| s.verdict_shown)))
|
||||
.collect();
|
||||
if !self.session.set_signature_captions(status_captions(lang)) {
|
||||
return;
|
||||
}
|
||||
for view in self.views.iter_mut().filter(|v| drawing.contains(&v.id)) {
|
||||
view.invalidate_content();
|
||||
}
|
||||
}
|
||||
|
||||
/// A file name without its PDF extension, for snapshot tab names.
|
||||
fn file_stem(name: &str) -> String {
|
||||
name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string()
|
||||
@@ -716,6 +739,44 @@ mod tests {
|
||||
assert_eq!(signature_status_style(SignatureStatus::Invalid).0, "circle-x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_language_change_leaves_documents_without_a_drawn_state_alone() {
|
||||
if !crate::i18n::needs_catalogs() {
|
||||
return;
|
||||
}
|
||||
let mut app = PdfCraftApp::new();
|
||||
let plain = app.session.create_from_text("t", "No signatures here.").unwrap();
|
||||
app.open_bytes("plain.pdf", None, plain.as_ref().clone()).unwrap();
|
||||
app.views[0].texts.insert(0, std::sync::Arc::new(pdfcraft_render::PageText::default()));
|
||||
app.apply_status_captions(crate::i18n::Lang::from_code("de").unwrap());
|
||||
assert!(!app.views[0].texts.is_empty(), "its text, selection and search results stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_captions_come_whole_from_one_language() {
|
||||
// All three in a language that has them, else all three in English: never a mix. Whether
|
||||
// a face draws them is the sign crate's call.
|
||||
use crate::i18n::{Lang, has};
|
||||
let english = StatusCaptions::default();
|
||||
assert_eq!(status_captions(Lang::EN), english);
|
||||
for lang in Lang::all().filter(|&l| l != Lang::EN) {
|
||||
let whole = [&english.valid, &english.unknown, &english.invalid].into_iter().all(|s| has(lang, s));
|
||||
let said = status_captions(lang);
|
||||
assert_eq!(said != english, whole, "{}: {said:?}", lang.code());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_captions_reach_the_sign_crate_in_logical_order() {
|
||||
// The sign crate receives the catalogue's logical order and shapes the words itself, so
|
||||
// they are never reversed a second time.
|
||||
if !crate::i18n::needs_catalogs() {
|
||||
return;
|
||||
}
|
||||
let ar = status_captions(crate::i18n::Lang::from_code("ar").unwrap());
|
||||
assert_eq!([ar.valid.as_str(), ar.unknown.as_str(), ar.invalid.as_str()], ["التوقيع صالح", "الصلاحية غير معروفة", "التوقيع غير صالح"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_digital_id_skips_names_already_taken_without_writing_through_them() {
|
||||
let dir = scratch();
|
||||
@@ -882,6 +943,7 @@ mod tests {
|
||||
timestamp_time: None,
|
||||
status,
|
||||
modification: sign::Modification::None,
|
||||
verdict_shown: false,
|
||||
details: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Align, Appearance, AppearanceLabels, Color32, CornerRadius, KEY_ALGORITHMS, Layout, NewIdDraft, PdfCraftApp, Rect, SavedSig, SignGraphic,
|
||||
SignStep, Stroke, Tokens, icons, sign, theme, vec2, widgets,
|
||||
SignStep, StatusCaptions, Stroke, Tokens, icons, sign, theme, vec2, widgets,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::file_filter::AddFilterAnyCase as _;
|
||||
@@ -289,6 +289,19 @@ pub(crate) fn appearance_labels(lang: crate::i18n::Lang) -> (AppearanceLabels, b
|
||||
}
|
||||
}
|
||||
|
||||
/// The words a named-layer signature appearance's status caption is drawn with: all three in
|
||||
/// `lang`, or all three in English when `lang` misses one of them. The sign crate picks the
|
||||
/// face and shapes right-to-left words itself, so they go in the catalogue's logical order.
|
||||
pub(crate) fn status_captions(lang: crate::i18n::Lang) -> StatusCaptions {
|
||||
let english = StatusCaptions::default();
|
||||
if lang == crate::i18n::Lang::EN {
|
||||
return english;
|
||||
}
|
||||
let said = |s: &str| crate::i18n::has(lang, s).then(|| crate::i18n::tr(lang, s).to_owned());
|
||||
(|| Some(StatusCaptions { valid: said(&english.valid)?, unknown: said(&english.unknown)?, invalid: said(&english.invalid)? }))()
|
||||
.unwrap_or(english)
|
||||
}
|
||||
|
||||
/// The detail lines of a visible signature, as `sign::pdf` stamps them (the distinguished name
|
||||
/// aside, which the dialog doesn't know before the ID is opened): the label above the name, then
|
||||
/// the reason, location and date, each after its label. `reason` and `location` are as typed.
|
||||
|
||||
@@ -44,11 +44,14 @@ fn status_icon(s: &SignatureInfo) -> (&'static str, Color32) {
|
||||
/// One look per signature verdict, shared by the Signatures panel and the page badges so
|
||||
/// the two can't drift apart.
|
||||
pub(crate) fn signature_status_style(status: SignatureStatus) -> (&'static str, Color32) {
|
||||
match status {
|
||||
SignatureStatus::Valid => ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78)),
|
||||
SignatureStatus::Unknown => ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19)),
|
||||
SignatureStatus::Invalid => ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F)),
|
||||
}
|
||||
// The colours the regenerated status layers draw with, so the page and the panel agree.
|
||||
let [r, g, b] = sign::status_rgb(status);
|
||||
let icon = match status {
|
||||
SignatureStatus::Valid => "circle-check",
|
||||
SignatureStatus::Unknown => "triangle-alert",
|
||||
SignatureStatus::Invalid => "circle-x",
|
||||
};
|
||||
(icon, Color32::from_rgb(r, g, b))
|
||||
}
|
||||
|
||||
/// What the Signatures panel asks the app to do.
|
||||
|
||||
@@ -77,6 +77,7 @@ mod scroll_prefetch;
|
||||
mod side_panels;
|
||||
mod signing;
|
||||
mod stamps;
|
||||
mod status_layers;
|
||||
mod text_select;
|
||||
mod theme;
|
||||
mod theme_button;
|
||||
|
||||
@@ -355,6 +355,59 @@ fn save_as_keeps_a_signed_document_incremental() {
|
||||
assert_eq!(status(&h), Some(SignatureStatus::Valid));
|
||||
}
|
||||
|
||||
/// A one-page document whose only signature field is signed but named far past the 4096 bytes the
|
||||
/// signature field walk reads, with a cross-reference table so it opens as written.
|
||||
fn signature_past_the_field_walk() -> Vec<u8> {
|
||||
let objs = [
|
||||
"<< /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 300 200] /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(64 << 10)),
|
||||
"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /ByteRange [0 1 2 1] /Contents <00> >>".into(),
|
||||
];
|
||||
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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_appends_to_a_document_whose_field_walk_passes_a_signature_over() {
|
||||
// The walk stops short of the signed field, so its signature is not listed; Save As must still
|
||||
// treat the document as signed and append to it, or that signature would no longer cover it.
|
||||
let bytes = signature_past_the_field_walk();
|
||||
let (original, out) = (bytes.clone(), dir().join("copy.pdf"));
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("form.pdf", None, bytes).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
// An edit to save: an unedited copy is the original's bytes, appended to or not.
|
||||
let shape = pdfcraft_engine::Shape::Rectangle { rect: [200.0, 120.0, 260.0, 180.0] };
|
||||
let style = pdfcraft_engine::Style::default_for(&shape);
|
||||
let note = pdfcraft_engine::NewAnnotation { page: 0, style, shape, contents: "Checked".into(), author: "Tester".into() };
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(note)));
|
||||
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
|
||||
assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::As), "the document can be saved as a copy");
|
||||
let saved = std::fs::read(&out).expect("file written");
|
||||
assert!(saved.starts_with(&original), "Save As rewrote the file instead of appending to it");
|
||||
let mut s = pdfcraft_engine::Session::new();
|
||||
let id = s.open("copy.pdf", None, std::sync::Arc::new(saved), None).expect("the copy opens");
|
||||
let notes: Vec<_> = s.get(id).unwrap().info.annotations.iter().filter_map(|a| a.contents.as_deref()).collect();
|
||||
assert!(notes.contains(&"Checked"), "the copy has the comment added before Save As: {notes:?}");
|
||||
}
|
||||
|
||||
/// The fixture with an empty signature field across the bottom of the page.
|
||||
const WITH_FIELD: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >> endobj
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
//! A named-layer signature appearance (layers n0–n4) in the real shell: the state drawn into it
|
||||
//! follows trust and the interface language, the page redraws without a zoom change, and the page
|
||||
//! badge stays whatever the state.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Pos2, Rect, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_engine::SignatureStatus;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
use crate::gpu;
|
||||
|
||||
/// A 400×300 page whose unsigned signature field (object 4) carries the named layers in the
|
||||
/// geometry of a real layered appearance scaled by 4; every word and drawing is synthetic. n1, a
|
||||
/// yellow question mark, is drawn into the square (146, 106)–(254, 214) of the page.
|
||||
fn fixture() -> Vec<u8> {
|
||||
fixture_with(1)
|
||||
}
|
||||
|
||||
/// [`fixture`], followed by blank pages up to `pages`.
|
||||
fn fixture_with(pages: usize) -> Vec<u8> {
|
||||
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 blank: Vec<String> = (1..pages).map(|i| format!("{} 0 R", 14 + i)).collect();
|
||||
let mut objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >>".to_string(),
|
||||
format!("<< /Type /Pages /Kids [3 0 R {}] /Count {pages} >>", blank.join(" ")),
|
||||
"<< /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(
|
||||
"[0 0 100 100]",
|
||||
"<< >>",
|
||||
"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(),
|
||||
];
|
||||
objs.extend((1..pages).map(|_| "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] >>".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
|
||||
}
|
||||
|
||||
/// Sign the fixture's field without touching its appearance: reserve `/ByteRange` and
|
||||
/// `/Contents`, hash the ranges and embed a detached CMS.
|
||||
fn signed(id: &pdfcraft_engine::sign::pkcs12::DigitalId) -> Vec<u8> {
|
||||
sign(id, fixture(), |_| {})
|
||||
}
|
||||
|
||||
/// [`signed`] for `file`, with `more` entries in the signature dictionary.
|
||||
fn sign(id: &pdfcraft_engine::sign::pkcs12::DigitalId, file: Vec<u8>, more: impl FnOnce(&mut pdfcraft_cos::Dict)) -> Vec<u8> {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object, PdfString};
|
||||
const MARK: &str = "0 1111111111 2222222222 3333333333";
|
||||
const RESERVE: usize = 8192;
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(file)).unwrap();
|
||||
let mut v = Dict::new();
|
||||
v.set(b"Type".to_vec(), Object::name("Sig"));
|
||||
v.set(b"SubFilter".to_vec(), Object::name("ETSI.CAdES.detached"));
|
||||
v.set(b"ByteRange".to_vec(), Object::Array([0, 1_111_111_111, 2_222_222_222, 3_333_333_333].map(Object::Int).to_vec()));
|
||||
v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; RESERVE], hex: true }));
|
||||
v.set(b"M".to_vec(), PdfString::literal(b"D:20261002120000+01'00'".to_vec()));
|
||||
more(&mut v);
|
||||
let sig = doc.add(Object::Dict(v));
|
||||
doc.update_dict(ObjRef::new(4, 0), |d| d.set(b"V".to_vec(), Object::Ref(sig))).unwrap();
|
||||
let save = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() };
|
||||
let mut out = pdfcraft_cos::write_incremental(&doc, &save).unwrap();
|
||||
let br = out.windows(MARK.len()).position(|w| w == MARK.as_bytes()).unwrap();
|
||||
let cstart = br + out[br..].windows(8).position(|w| w == b"<0000000").unwrap();
|
||||
let cend = cstart + RESERVE * 2 + 2;
|
||||
let mut range = format!("0 {cstart} {cend} {}", out.len() - cend).into_bytes();
|
||||
range.resize(MARK.len(), b' ');
|
||||
out[br..br + MARK.len()].copy_from_slice(&range);
|
||||
let alg = id.key.preferred_digest();
|
||||
let digest = alg.digest(&[&out[..cstart], &out[cend..]]);
|
||||
let cms = pdfcraft_engine::sign::cms::sign_detached(&id.key, &id.certificate, &id.chain, alg, &digest).unwrap();
|
||||
let hex: Vec<u8> = cms.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect();
|
||||
out[cstart + 1..cstart + 1 + hex.len()].copy_from_slice(&hex);
|
||||
out
|
||||
}
|
||||
|
||||
/// Let the page render finish.
|
||||
fn settle(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
for _ in 0..400 {
|
||||
h.run_steps(1);
|
||||
if !h.state().render_pending() {
|
||||
h.run_steps(2);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
panic!("the page never finished rendering");
|
||||
}
|
||||
|
||||
/// The screen point of page point (x, y) on the 400×300 page.
|
||||
fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 {
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
|
||||
pos2(r.left() + x / 400.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height())
|
||||
}
|
||||
|
||||
/// How many rendered pixels in n1's square are within 40 of `rgb` on every channel.
|
||||
fn in_n1_square(h: &mut Harness<'static, PdfCraftApp>, rgb: [u8; 3]) -> usize {
|
||||
let ppp = h.ctx.pixels_per_point();
|
||||
let square = Rect::from_two_pos(at(h, 146.0, 106.0), at(h, 254.0, 214.0));
|
||||
let image = h.render().unwrap();
|
||||
let (x0, y0, x1, y1) = ((square.left() * ppp) as u32, (square.top() * ppp) as u32, (square.right() * ppp) as u32, (square.bottom() * ppp) as u32);
|
||||
(y0..y1.min(image.height()))
|
||||
.flat_map(|y| (x0..x1.min(image.width())).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| image.get_pixel(x, y).0.iter().zip(rgb).all(|(a, b)| a.abs_diff(b) <= 40))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// The rendered pixels of n4's caption strip, (100, 184)–(300, 220) of the page.
|
||||
fn caption_strip(h: &mut Harness<'static, PdfCraftApp>) -> Vec<u8> {
|
||||
let ppp = h.ctx.pixels_per_point();
|
||||
let strip = Rect::from_two_pos(at(h, 100.0, 184.0), at(h, 300.0, 220.0));
|
||||
let image = h.render().unwrap();
|
||||
let (x0, y0, x1, y1) = ((strip.left() * ppp) as u32, (strip.top() * ppp) as u32, (strip.right() * ppp) as u32, (strip.bottom() * ppp) as u32);
|
||||
(y0..y1.min(image.height())).flat_map(|y| (x0..x1.min(image.width())).map(move |x| (x, y))).flat_map(|(x, y)| image.get_pixel(x, y).0).collect()
|
||||
}
|
||||
|
||||
/// Redraw the open document with `captions`, invalidate every view's page cache, and return the
|
||||
/// caption's rendered pixels.
|
||||
fn redraw_captions(h: &mut Harness<'static, PdfCraftApp>, captions: pdfcraft_engine::sign::StatusCaptions) -> Vec<u8> {
|
||||
let app = h.state_mut();
|
||||
app.session.set_signature_captions(captions);
|
||||
for view in &mut app.views {
|
||||
view.invalidate_content();
|
||||
}
|
||||
settle(h);
|
||||
caption_strip(h)
|
||||
}
|
||||
|
||||
/// How many nodes read `label` while the pointer rests on the signature: the badge's tooltip adds
|
||||
/// one, a widget without a badge none.
|
||||
fn hovered(h: &mut Harness<'static, PdfCraftApp>, label: &str) -> usize {
|
||||
h.hover_at(at(h, 120.0, 130.0));
|
||||
h.run_steps(3);
|
||||
let n = h.query_all_by_label(label).count();
|
||||
h.hover_at(pos2(5.0, 5.0));
|
||||
h.run_steps(2);
|
||||
n
|
||||
}
|
||||
|
||||
/// The app with `bytes` open in `lang`, the left panel closed, settled.
|
||||
fn harness(bytes: Vec<u8>, lang: &'static str) -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", lang).unwrap();
|
||||
app.open_bytes("signed.pdf", None, bytes).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
// Field highlights tint the widget; these tests read the colours its appearance draws.
|
||||
app.set_option("fields", "off").unwrap();
|
||||
app
|
||||
});
|
||||
settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_layers_follow_trust_and_language_and_keep_the_badge() {
|
||||
let _gpu = gpu();
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let id = pdfcraft_engine::sign::pkcs12::open(&p12, "test").unwrap();
|
||||
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
|
||||
let mut h = harness(signed(&id), "en");
|
||||
let (amber, green) = ([0xE6, 0x86, 0x19], [0x2D, 0x9D, 0x78]);
|
||||
let doc_id = h.state().views[0].id;
|
||||
// Untrusted: the appearance draws the unknown state, and the badge stays.
|
||||
assert_eq!(h.state().session.get(doc_id).unwrap().signatures[0].status, SignatureStatus::Unknown);
|
||||
let unknown = in_n1_square(&mut h, amber);
|
||||
assert!(unknown > 100, "n1 shows the unknown icon: {unknown} amber pixels");
|
||||
assert!(
|
||||
hovered(&mut h, "Signature validity is unknown") > h.query_all_by_label("Signature validity is unknown").count(),
|
||||
"an unknown signature keeps its badge"
|
||||
);
|
||||
// Trusted: the same page, at the same zoom, redraws in the valid state. The badge stays:
|
||||
// whether the layers are visible is the document's to decide.
|
||||
let zoom = h.state().views[0].zoom;
|
||||
h.state_mut().apply_trusted_certificates(vec![anchor]);
|
||||
settle(&mut h);
|
||||
assert_eq!(h.state().session.get(doc_id).unwrap().signatures[0].status, SignatureStatus::Valid);
|
||||
assert_eq!(h.state().views[0].zoom, zoom);
|
||||
let (valid, unknown) = (in_n1_square(&mut h, green), in_n1_square(&mut h, amber));
|
||||
assert!(valid > 100 && unknown < 10, "n1 redraws in the valid state: {valid} green, {unknown} amber pixels");
|
||||
assert!(hovered(&mut h, "Signature is valid") > h.query_all_by_label("Signature is valid").count(), "a valid signature keeps its badge");
|
||||
// The interface language changes the caption, on the page as well as in the document.
|
||||
if crate::support::skip_without_catalogs("the German captions of named_layers_follow_trust_and_language_and_keep_the_badge") {
|
||||
return;
|
||||
}
|
||||
let english = caption_strip(&mut h);
|
||||
h.state_mut().set_option("language", "de").unwrap();
|
||||
settle(&mut h);
|
||||
// Compared whole, not with assert_ne!: a failure would print both strips, byte by byte.
|
||||
assert!(caption_strip(&mut h) != english, "the page redraws the caption");
|
||||
let text = pdfcraft_engine::export::Exporter::new(h.state().session.get(doc_id).unwrap()).text_of(&[0]).unwrap();
|
||||
let de = pdfcraft_ui_egui::i18n::tr(pdfcraft_ui_egui::i18n::Lang::from_code("de").unwrap(), "Signature valid");
|
||||
assert!(text.contains(de), "{text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_badge_tip_stays_hidden_while_a_dialog_covers_the_page() {
|
||||
// Tips are drawn above windows, so one over a dialog would show through it.
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let id = pdfcraft_engine::sign::pkcs12::open(&p12, "test").unwrap();
|
||||
let mut h = harness(signed(&id), "en");
|
||||
h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::About);
|
||||
h.run_steps(2);
|
||||
let label = "Signature validity is unknown";
|
||||
assert_eq!(hovered(&mut h, label), h.query_all_by_label(label).count(), "no badge tip over a dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_badge_tip_stays_off_where_a_comment_tip_would() {
|
||||
// Fill & Sign keeps its placement clear, and a freehand stroke isn't covered as it is drawn.
|
||||
use pdfcraft_ui_egui::QuickTool;
|
||||
use pdfcraft_ui_egui::comments::CommentTool;
|
||||
use pdfcraft_ui_egui::fill_sign::FillTool;
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let id = pdfcraft_engine::sign::pkcs12::open(&p12, "test").unwrap();
|
||||
let mut h = harness(signed(&id), "en");
|
||||
let label = "Signature validity is unknown";
|
||||
for (tool, tip) in [(QuickTool::Select, true), (QuickTool::Fill(FillTool::Text), false), (QuickTool::Comment(CommentTool::Ink), false)] {
|
||||
h.state_mut().quick_tool = tool;
|
||||
h.run_steps(2);
|
||||
assert_eq!(hovered(&mut h, label) > h.query_all_by_label(label).count(), tip, "{tool:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_caption_keeps_logical_word_order_on_page_and_in_extracted_text() {
|
||||
let _gpu = gpu();
|
||||
if pdfcraft_fonts::document_arabic_font().is_none() {
|
||||
eprintln!("skipping Arabic status-caption rendering: build without a craft-fonts Arab face");
|
||||
return;
|
||||
}
|
||||
if crate::support::skip_without_catalogs("arabic_caption_keeps_logical_word_order_on_page_and_in_extracted_text") {
|
||||
return;
|
||||
}
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let id = pdfcraft_engine::sign::pkcs12::open(&p12, "test").unwrap();
|
||||
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
|
||||
let mut h = harness(signed(&id), "ar");
|
||||
h.state_mut().apply_trusted_certificates(vec![anchor]);
|
||||
settle(&mut h);
|
||||
|
||||
let from_interface = caption_strip(&mut h);
|
||||
let logical = pdfcraft_engine::sign::StatusCaptions {
|
||||
valid: "التوقيع صالح".into(),
|
||||
unknown: "الصلاحية غير معروفة".into(),
|
||||
invalid: "التوقيع غير صالح".into(),
|
||||
};
|
||||
// The same words in display order, as a catalogue preordered for the screen would hold them.
|
||||
let reversed = |s: &str| s.split(' ').rev().collect::<Vec<_>>().join(" ");
|
||||
let display_order = pdfcraft_engine::sign::StatusCaptions {
|
||||
valid: reversed(&logical.valid),
|
||||
unknown: reversed(&logical.unknown),
|
||||
invalid: reversed(&logical.invalid),
|
||||
};
|
||||
assert_ne!(logical.valid, display_order.valid, "the regression needs a multiword caption");
|
||||
let doc_id = h.state().views[0].id;
|
||||
let doc = h.state().session.get(doc_id).unwrap();
|
||||
// The caption is drawn as outlines; text extraction reads it from its /ActualText.
|
||||
let text = pdfcraft_engine::export::Exporter::new(doc).text_of(&[0]).unwrap();
|
||||
assert!(text.contains(&logical.valid), "text extraction reads the caption in logical order: {text:?}");
|
||||
assert!(!text.contains(&display_order.valid), "{text:?}");
|
||||
|
||||
let logical_render = redraw_captions(&mut h, logical);
|
||||
let double_reordered_render = redraw_captions(&mut h, display_order);
|
||||
// Compared whole: assert_eq! would print both strips, byte by byte.
|
||||
assert!(from_interface == logical_render, "the interface path renders the logical-order caption");
|
||||
assert!(from_interface != double_reordered_render, "display-order input reverses the rendered word order a second time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_on_another_page_that_changes_the_verdict_redraws_the_signature() {
|
||||
let _gpu = gpu();
|
||||
// A certification that allows no changes, on page 1. A comment on page 2 invalidates it, so
|
||||
// page 1 has to redraw although the edit was elsewhere.
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let id = pdfcraft_engine::sign::pkcs12::open(&p12, "test").unwrap();
|
||||
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
|
||||
let bytes = sign(&id, fixture_with(2), |v| {
|
||||
let reference = b"[<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 1 /V /1.2 >> >>]";
|
||||
v.set(b"Reference".to_vec(), pdfcraft_cos::Lexer::new(reference, 0).object().unwrap());
|
||||
});
|
||||
let mut h = harness(bytes, "en");
|
||||
h.state_mut().apply_trusted_certificates(vec![anchor]);
|
||||
settle(&mut h);
|
||||
let (green, red) = ([0x2D, 0x9D, 0x78], [0xD7, 0x37, 0x3F]);
|
||||
let doc_id = h.state().views[0].id;
|
||||
assert_eq!(h.state().session.get(doc_id).unwrap().signatures[0].status, SignatureStatus::Valid);
|
||||
let valid = in_n1_square(&mut h, green);
|
||||
assert!(valid > 100, "page 1 draws the valid state: {valid} green pixels");
|
||||
|
||||
let shape = pdfcraft_engine::Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] };
|
||||
let note = pdfcraft_engine::NewAnnotation {
|
||||
page: 1,
|
||||
style: pdfcraft_engine::Style::default_for(&shape),
|
||||
shape,
|
||||
contents: String::new(),
|
||||
author: "Tester".into(),
|
||||
};
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(note)));
|
||||
settle(&mut h);
|
||||
assert_eq!(h.state().session.get(doc_id).unwrap().signatures[0].status, SignatureStatus::Invalid);
|
||||
let (invalid, valid) = (in_n1_square(&mut h, red), in_n1_square(&mut h, green));
|
||||
assert!(invalid > 100 && valid < 10, "page 1 redraws in the invalid state: {invalid} red, {valid} green pixels");
|
||||
}
|
||||
|
||||
/// A text field, "name", below the signature on page 1.
|
||||
const NAME_FIELD: &[u8] = b"<< /FT /Tx /T (name) /Type /Annot /Subtype /Widget /Rect [100 40 300 70] /P 3 0 R /DA (/Helv 12 Tf 0 g) >>";
|
||||
|
||||
/// `file` with `fields`, each one widget on page 1, added to its form.
|
||||
fn with_fields(file: Vec<u8>, fields: &[&[u8]]) -> Vec<u8> {
|
||||
use pdfcraft_cos::{Lexer, ObjRef, Object};
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(file)).unwrap();
|
||||
for field in fields {
|
||||
let field = doc.add(Lexer::new(field, 0).object().unwrap());
|
||||
for (owner, key) in [(ObjRef::new(5, 0), &b"Fields"[..]), (ObjRef::new(3, 0), b"Annots")] {
|
||||
doc.update_dict(owner, |d| {
|
||||
if let Some(Object::Array(a)) = d.get_mut(key) {
|
||||
a.push(Object::Ref(field));
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_typed_against_a_certification_shows_while_every_page_redraws() {
|
||||
// A certification that allows no changes: filling a field breaks it, so every page redraws,
|
||||
// the field's own included. Until that page's raster arrives, the value typed is drawn over it.
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let id = pdfcraft_engine::sign::pkcs12::open(&p12, "test").unwrap();
|
||||
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
|
||||
let bytes = sign(&id, with_fields(fixture(), &[NAME_FIELD]), |v| {
|
||||
let reference = b"[<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 1 /V /1.2 >> >>]";
|
||||
v.set(b"Reference".to_vec(), pdfcraft_cos::Lexer::new(reference, 0).object().unwrap());
|
||||
});
|
||||
let mut h = harness(bytes, "en");
|
||||
h.state_mut().apply_trusted_certificates(vec![anchor]);
|
||||
settle(&mut h);
|
||||
let doc_id = h.state().views[0].id;
|
||||
assert_eq!(h.state().session.get(doc_id).unwrap().signatures[0].status, SignatureStatus::Valid);
|
||||
|
||||
let value = pdfcraft_engine::FieldValue::Text("Ada".into());
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldValue { name: "name".into(), value }));
|
||||
assert_eq!(h.state().session.get(doc_id).unwrap().signatures[0].status, SignatureStatus::Invalid);
|
||||
assert!(h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0)), "the value typed is pending until page 1 redraws");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_still_waiting_on_their_page_stay_pending_when_a_later_edit_redraws_every_page() {
|
||||
// A certification that allows filling in fields: the fill leaves every state as it was. Each
|
||||
// second edit changes the list, deleting the unsigned signature field or breaking the
|
||||
// certification with a comment, so every page redraws; neither moves a page, so the value typed
|
||||
// first stays pending, drawn over page 1's old raster until the new one arrives.
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let id = pdfcraft_engine::sign::pkcs12::open(&p12, "test").unwrap();
|
||||
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
|
||||
let unsigned: &[u8] = b"<< /FT /Sig /T (Signature2) /Type /Annot /Subtype /Widget /Rect [100 230 300 280] /P 3 0 R >>";
|
||||
let shape = pdfcraft_engine::Shape::Rectangle { rect: [320.0, 20.0, 380.0, 80.0] };
|
||||
let style = pdfcraft_engine::Style::default_for(&shape);
|
||||
let comment = pdfcraft_engine::NewAnnotation { page: 0, style, shape, contents: String::new(), author: "Tester".into() };
|
||||
for second in [pdfcraft_engine::Edit::DeleteField { name: "Signature2".into() }, pdfcraft_engine::Edit::AddAnnotation(comment)] {
|
||||
let bytes = sign(&id, with_fields(fixture(), &[NAME_FIELD, unsigned]), |v| {
|
||||
let reference = b"[<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 2 /V /1.2 >> >>]";
|
||||
v.set(b"Reference".to_vec(), pdfcraft_cos::Lexer::new(reference, 0).object().unwrap());
|
||||
});
|
||||
let mut h = harness(bytes, "en");
|
||||
h.state_mut().apply_trusted_certificates(vec![anchor.clone()]);
|
||||
settle(&mut h);
|
||||
let doc_id = h.state().views[0].id;
|
||||
let states = |h: &Harness<'static, PdfCraftApp>| {
|
||||
h.state().session.get(doc_id).unwrap().signatures.iter().map(|s| (s.field.clone(), s.status, s.verdict_shown)).collect::<Vec<_>>()
|
||||
};
|
||||
let before = states(&h);
|
||||
let value = pdfcraft_engine::FieldValue::Text("Ada".into());
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldValue { name: "name".into(), value }));
|
||||
assert_eq!(states(&h), before, "the certification allows filling in a field");
|
||||
assert!(h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0)));
|
||||
|
||||
let label = second.label();
|
||||
assert!(h.state_mut().apply_edit(second), "{label}");
|
||||
assert_ne!(states(&h), before, "{label} changes the list");
|
||||
assert!(h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0)), "{label}: the value typed first is still pending");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_opened_before_the_first_frame_is_drawn_in_the_interface_language() {
|
||||
// Files named at startup open before any frame, which is when the interface language used to
|
||||
// reach the captions: drawn in English, then again.
|
||||
if crate::support::skip_without_catalogs("a_file_opened_before_the_first_frame_is_drawn_in_the_interface_language") {
|
||||
return;
|
||||
}
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let id = pdfcraft_engine::sign::pkcs12::open(&p12, "test").unwrap();
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "de").unwrap();
|
||||
app.open_bytes("named.pdf", None, signed(&id)).expect("opens");
|
||||
let doc = app.session.get(app.views[0].id).unwrap();
|
||||
let text = pdfcraft_engine::export::Exporter::new(doc).text_of(&[0]).unwrap();
|
||||
assert!(text.contains("Gültigkeit unbekannt"), "{text:?}");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Interface language
|
||||
|
||||
Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **کوردیی ناوەندی**, **فارسی**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių**, **Tiếng Việt**, **Suomi**, **Shqip**, **Català**, **Română** or **Norsk bokmål**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
|
||||
Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **کوردیی ناوەندی**, **فارسی**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių**, **Tiếng Việt**, **Suomi**, **Shqip**, **Català**, **Română** or **Norsk bokmål**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. The status caption PdfCraft draws into a named-layer signature appearance ("Signature valid" and its siblings) follows it too, on screen, in exports and in print; it is never saved. It is drawn in Helvetica, or shaped in embedded Inter, or shaped in a craft-fonts face for Arabic, Japanese, Telugu, Thai and Devanagari when the build has one (drawn as outlines), and otherwise in English, as Chinese always is; saving never writes it.
|
||||
|
||||
Displayed keyboard shortcuts use the same active interface language. For example, German shows `Strg+K` and `Strg+Umschalt+S` in the toolbar, menus, command palette, tooltips and Keyboard shortcuts dialog; macOS keeps its modifier symbols (`⌘`, `⇧`, `⌃`). Key names have their own `key` catalog context, so the Delete key can read `Entf` while the command remains `Löschen`. A missing key-name entry falls back to the English key name. Actual bindings and machine-facing command ids remain stable.
|
||||
|
||||
|
||||
+4
-1
@@ -246,7 +246,7 @@ total matches the parity estimate.
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. Prepare a form ▸ Calculation order, a page of its own: each calculated field's formula and value, what is wrong (fields that calculate too early, loops, fields missing from `/CO`) and Fix order, Up/Down, Alt+↑/↓ and drag, and on the page each field's step and arrows to and from the field in focus (`form_calculation_order` headlessly). |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Retained text/image-bearing or unreadable tiling-pattern cells now make Apply refuse atomically after a bounded check of rewritten resources; fully removed cells still apply, and cross-page retention is checked. The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
|
||||
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork, placed in the widget's bottom-right corner as it lies on the page, at a quarter of its shorter side and 48 screen points at most, so they annotate the artwork from a corner instead of covering the middle of it and turn with the page and the view (a field falling under 32 screen points, as a page does when zoomed out to fit, keeps its outline), snapshot tabs that reuse instead of multiplying, named-layer signature appearances redrawn for their real state (#79; display, the CLI's render, exports and printing, never saves; captions in the interface language, Chinese in English; the badge stays). Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). |
|
||||
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178); black-and-white images stored losslessly as CCITT Group 4 when smaller, averaged down to 300 ppi above 450 (halftones aside), Group 3 stored again as Group 4, and black-and-white RGB and indexed images that use only palette entries 0 and 1 packed to one bit. Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
|
||||
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA<br>Reading colours: pending page backgrounds follow the configured colour (#2059). |
|
||||
@@ -333,6 +333,7 @@ Estimates unchanged.
|
||||
| 2026-10-29 | minor | M7 #2517: widening a paragraph rejoins a wrap with the spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split, after an incremental save and a full rewrite. Estimates unchanged. |
|
||||
| 2026-10-29 | minor | M2: non-interpolated images drawn below their resolution are area-averaged to whole device pixels instead of nearest-neighbour (scanned text kept one sample in N per axis); page-scan-size images are smoothed when enlarged. 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-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. 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 | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
|
||||
@@ -364,6 +365,8 @@ Estimates unchanged.
|
||||
| 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: Edit text caret height follows its row, with a kittest regression; estimates unchanged |
|
||||
| 2026-10-19 | minor | M3 #3360: retain each document’s scroll position when switching Read and the docking workspace at fixed zoom. 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. |
|
||||
|
||||
+3
-2
@@ -25,8 +25,9 @@ Explicit Table/Code/Figure marked content still works there. Cyclic and oversize
|
||||
graphs are bounded and reported as incomplete.
|
||||
|
||||
PDF `/ActualText` replacements are respected. If one replacement spans both included and
|
||||
excluded glyphs, there is no reliable mapping of its words to the selected content: counting
|
||||
fails with an explanation instead of silently returning the wrong total. Disable exclusions to
|
||||
excluded glyphs (or, with no glyphs, included and excluded outlines or pictures), there is no
|
||||
reliable mapping of its words to the selected content: counting fails with an explanation
|
||||
instead of silently returning the wrong total. Disable exclusions to
|
||||
count that page. Unreadable pages likewise fail the operation, without a misleading partial total.
|
||||
|
||||
Images alone contain no extractable words. Run Scan & OCR first when counting scanned pages.
|
||||
|
||||
@@ -4613,9 +4613,9 @@ tier = "P0"
|
||||
milestone = "M2"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::button_states_without_an_appearance"]
|
||||
notes = "widgets retain their original artwork. Signature validation is a separate viewer page overlay; it is never written into document streams or included in image/text exports, vector printing or Print as Image. Saves retain signed appearance bytes. Generic in-place appearance rewriting is deferred: no supported producer template is positively identified, and caption text plus drawing uniqueness does not establish an icon role. Baked Not Verified captions remain unchanged; issue #79 is not fully resolved. Check boxes and radio buttons with no appearance for their state are shown (display-only) with their /MK border and background and, when on, the /MK /CA ZapfDingbats symbol in the /DA colour (a check or a dot by default); push buttons with none show their /MK frame and caption; files that carry their own appearances are not redrawn."
|
||||
tools = ["page_render", "sign_list"]
|
||||
evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::button_states_without_an_appearance", "crates/ui-egui/src/canvas/tests.rs::the_verdict_mark_takes_the_corner_nearest_its_anchor", "crates/ui-egui/src/canvas/tests.rs::the_verdict_mark_scales_with_the_widget_until_the_cap", "crates/ui-egui/src/canvas/tests.rs::the_verdict_mark_stays_inside_its_widget_at_every_finite_size", "crates/sign/src/status_layers.rs::has_status_layers_recognises_only_named_layer_layouts", "crates/sign/src/status_layers.rs::captions_in_other_scripts_use_a_face_made_for_them_or_english", "crates/sign/src/status_layers.rs::the_originals_are_untouched_and_the_widget_points_at_copies", "crates/sign/src/status_layers.rs::two_fields_sharing_a_layer_form_each_show_their_own_state", "crates/engine/src/tests.rs::named_layer_signatures_show_their_state_through_trust_save_and_reopen", "crates/engine/src/tests.rs::named_layer_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_tampered_named_layer_signature_shows_the_invalid_state", "crates/engine/src/tests.rs::named_layer_states_reach_exports_and_both_print_modes", "crates/engine/src/tests.rs::caption_language_follows_the_session_captions", "crates/ui-egui/tests/it/status_layers.rs::named_layers_follow_trust_and_language_and_keep_the_badge", "crates/ui-egui/tests/it/status_layers.rs::a_badge_tip_stays_hidden_while_a_dialog_covers_the_page", "crates/ui-egui/tests/it/status_layers.rs::arabic_caption_keeps_logical_word_order_on_page_and_in_extracted_text", "crates/ui-egui/tests/it/status_layers.rs::an_edit_on_another_page_that_changes_the_verdict_redraws_the_signature", "crates/ui-egui/tests/it/status_layers.rs::a_file_opened_before_the_first_frame_is_drawn_in_the_interface_language", "crates/ui-egui/src/canvas/tests.rs::signature_badges_keep_every_verdict_the_appearance_shows", "crates/ui-egui/src/canvas/tests.rs::the_verdict_mark_turns_with_the_page_and_the_view", "crates/engine/src/tests.rs::cli_display_draws_signature_states_as_the_app_does_without_trust", "crates/render/src/text/tests.rs::actual_text_over_painted_paths_reads_where_they_are_painted", "crates/render/src/text/tests.rs::actual_text_over_paint_reads_only_where_it_shows", "crates/automation/tests/it/automation.rs::named_layer_verdicts_are_reported_and_rendered_through_tools", "crates/sign/src/status_layers.rs::shared_appearances_are_copied_once_however_many_widgets_draw_them"]
|
||||
notes = "widgets retain their original artwork, except a signature widget whose /N draws, from its own resources, exactly one Form that holds and draws both n1 and n3 (the named-layer convention): there n1, n3 and n4 are redrawn for the signature's state, with the panel's Lucide icons (circle-check, triangle-alert, circle-x) and Signature valid, Validity unknown or Signature invalid in the interface language. The caption is in Helvetica when WinAnsi encodes the words; otherwise shaped in embedded Inter (Latin, Cyrillic, Greek), or in a craft-fonts face for Arabic, Japanese, Telugu, Thai and Devanagari when the build has one, drawn as filled outlines (the Arabic and Telugu faces are variable fonts, which can't be embedded); otherwise English, as Chinese always is. The redraw lives in the display copy only, built from copies made once per original and state, so a stream shared between fields never carries one verdict onto another and shared streams are not multiplied. The screen, the CLI's render (no trusted certificates), image and text exports (text extraction reads every caption, an outline one through its /ActualText at the outlines' visible box), Print as Image and vector printing show it; saves never do, so the signed bytes and the signature stay as they were. An edit that changes a verdict redraws every page, not only the edited one. Every signature, recognised or not and whatever its state, also gets a separate viewer overlay: the widget's outline and a mark at a quarter of its shorter side (48 screen points at most) in its bottom-right corner as it lies on the page, so the mark turns with the page's /Rotate and the view, keeps off the middle of the signer's artwork and clear of a named-layer caption across the top. It stays because a document's own layers can clip or cover the drawn state. A widget whose shorter side falls under 32 screen points (as fields do with the page zoomed out to fit) carries the verdict in its outline alone, and a widget whose rectangle the screen cannot place is not drawn at all. The overlay answers the pointer over the whole widget, as far as the page goes, except under a dialog, Fill & Sign or freehand drawing; it is never document content. Not covered: an /N drawing two qualifying layer forms (refused), a recognised appearance that turns itself against a rotated page (its mark may meet the caption), and overlays for fields whose first widget is not the visible one. Check boxes and radio buttons with no appearance for their state are shown (display-only) with their /MK border and background and, when on, the /MK /CA ZapfDingbats symbol in the /DA colour (a check or a dot by default); push buttons with none show their /MK frame and caption; files that carry their own appearances are not redrawn."
|
||||
|
||||
[[feature]]
|
||||
id = "form.fill-text"
|
||||
@@ -5955,8 +5955,8 @@ milestone = "M8"
|
||||
status = "shipped"
|
||||
commands = ["redact.apply"]
|
||||
tools = ["redact_apply"]
|
||||
evidence = ["crates/redact/src/tests.rs::tags_lose_what_redaction_removed", "crates/redact/src/tests.rs::applied_redactions_drop_structure_tree_references_to_what_they_remove"]
|
||||
notes = "marked content changed by redaction is found by comparing each MCID before and after; its elements lose Alt, ActualText and E, and references to now-empty marked content are dropped; object references (OBJR) to the comments and form fields redaction removes are dropped too (#2874); untouched tags stay"
|
||||
evidence = ["crates/redact/src/tests.rs::tags_lose_what_redaction_removed", "crates/redact/src/tests.rs::applied_redactions_drop_structure_tree_references_to_what_they_remove", "crates/redact/src/tests.rs::redacted_paint_loses_the_actual_text_that_names_it", "crates/engine/src/tests.rs::a_word_drawn_as_outlines_is_redacted_with_its_name"]
|
||||
notes = "marked content changed by redaction is found by comparing each MCID before and after; its elements lose Alt, ActualText and E, and references to now-empty marked content are dropped; object references (OBJR) to the comments and form fields redaction removes are dropped too (#2874); untouched tags stay. Inline marked content around anything redaction removed, changed or clipped (a path partly under a mark is removed, a shading clipped) loses its /ActualText, /Alt, /E and /T, so a word drawn as outlines and partly redacted leaves no name behind"
|
||||
|
||||
[[feature]]
|
||||
id = "protect.redaction-verification"
|
||||
@@ -6427,8 +6427,8 @@ milestone = "M9"
|
||||
status = "shipped"
|
||||
commands = ["sign.panel"]
|
||||
tools = ["sign_list"]
|
||||
evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/sign/tests/it/pdf.rs::a_hidden_signature_field_is_listed_as_not_visible"]
|
||||
notes = "sign_list reports a signature as visible only when its widget has a box and isn't Hidden or NoView, as form widgets are read, so a hidden field gets no badge on the page"
|
||||
evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/sign/tests/it/pdf.rs::a_hidden_signature_field_is_listed_as_not_visible", "crates/engine/src/tests.rs::named_layer_signatures_show_their_state_through_trust_save_and_reopen", "crates/ui-egui/tests/it/status_layers.rs::named_layers_follow_trust_and_language_and_keep_the_badge"]
|
||||
notes = "sign_list reports a signature as visible only when its widget has a box and isn't Hidden or NoView, as form widgets are read, so a hidden field gets no badge on the page. The state also shows inside named-layer signature appearances (icon in n1 or n3, caption in n4; see form.field-appearance-display), in the same colours as the panel and badge."
|
||||
|
||||
[[feature]]
|
||||
id = "sign.validation-banner"
|
||||
@@ -6440,7 +6440,7 @@ status = "shipped"
|
||||
commands = ["sign.panel"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/it/signing.rs::certifying_without_a_visible_signature", "crates/ui-egui/src/canvas/tests.rs::signature_badges_keep_signed_visible_widgets_on_the_page"]
|
||||
notes = "signed/valid, problems, invalid and certified bars; visible signed widgets also carry a separate viewer validation overlay. Trust and explicit validation invalidate view caches without editing document artwork; baked appearance captions remain unchanged."
|
||||
notes = "signed/valid, problems, invalid and certified bars; visible signed widgets also carry a separate viewer validation overlay. Trust and explicit validation invalidate view caches without editing saved document artwork; recognised named-layer appearances are redrawn only in the display copy, exports and print paths."
|
||||
|
||||
[[feature]]
|
||||
id = "sign.signature-properties"
|
||||
|
||||
Reference in new issue
Block a user