From faef5792963cbc11842b2d0ac9ae3e59e3b20887 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 22 Oct 2026 17:58:11 +0700 Subject: [PATCH 001/142] README: the app icon above the title --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 7d3069258..d39bec5aa 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@

+

+ PdfCraft icon: an engraved lion on a teal tile +

+

PdfCraft

From 80b7d21525b263621818427b0223560101d1325f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 17:20:39 -0400 Subject: [PATCH 002/142] fix: add generic font-family fallbacks in demo (SonarCloud css:S4649) Each font-family in assets/demo/showcase.html named only a specific font with no generic fallback. Add an appropriate generic (serif/sans-serif/monospace/ cursive/math) so text still renders if the specific face is unavailable. --- assets/demo/showcase.html | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/assets/demo/showcase.html b/assets/demo/showcase.html index 267e56a56..22e0619ea 100644 --- a/assets/demo/showcase.html +++ b/assets/demo/showcase.html @@ -401,12 +401,12 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;

The families in this specimen

-
AgPlayfair DisplayDisplay
-
AgEB GaramondBody text
-
AgInterLabels
-
AgJetBrains MonoCode
-
AgGreat VibesScript
-
∑xSTIX Two MathMathematics
+
AgPlayfair DisplayDisplay
+
AgEB GaramondBody text
+
AgInterLabels
+
AgJetBrains MonoCode
+
AgGreat VibesScript
+
∑xSTIX Two MathMathematics
Forewordii
@@ -449,7 +449,7 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt; onumOld-style figuresSince 1885: 3,467,920Since 1885: 3,467,920 fracFractions1/2 cup, 3/4 inch, 7/81/2 cup, 3/4 inch, 7/8 swshSwash capitalsThe Quiet RiverThe Quiet River - ss01Stylistic set 1a 1 4 6 9 G ?a 1 4 6 9 G ? + ss01Stylistic set 1a 1 4 6 9 G ?a 1 4 6 9 G ? kernKerningAVATAR Type WaveAVATAR Type Wave tnumTabular figures1111 / 88881111 / 8888 @@ -519,9 +519,9 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;

A short greeting — roughly, “every letter deserves a good home” — in writing systems that need bidirectional layout, contextual shaping, reordering and vertical setting.

-
Arabic RTL · joining
لكل حرف بيت
Amiri
+
Arabic RTL · joining
لكل حرف بيت
Amiri
Hebrew RTL
לכל אות יש בית טוב
Noto Sans Hebrew
-
Devanagari conjuncts
हर अक्षर का घर
Noto Sans Devanagari
+
Devanagari conjuncts
हर अक्षर का घर
Noto Sans Devanagari
Thai no spaces
ทุกอักษรมีบ้าน
Noto Sans Thai
Greek polytonic
Κάθε γράμμα ἔχει σπίτι
EB Garamond
Cyrillic
Каждой букве — дом
EB Garamond
@@ -541,7 +541,7 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;

Mixed direction in one line

-

The word كتاب means “book”, and ספר does too — the bidi algorithm keeps each run in its own direction while the sentence stays left-to-right, even across numbers like ٣٤٥ and 345.

+

The word كتاب means “book”, and ספר does too — the bidi algorithm keeps each run in its own direction while the sentence stays left-to-right, even across numbers like ٣٤٥ and 345.

Colour emoji

📄🖋️🎨📐🔍🌍🧮✅🦀📎
From 9526363152b780ff82fc45d1c1e8ac3963fec904 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:28:58 -0400 Subject: [PATCH 003/142] Build each crate's integration tests as one binary (#4087) * Build each crate's integration tests as one binary Every tests/*.rs file is its own executable that links the whole crate graph. Move each crate's test files into tests/it/ as modules of one tests/it/main.rs, for apps/pdfcraft, apps/pdfcraft-cli, automation, cos, crypt, engine, render, sign and ui-egui. ui-egui's ui_group binary and the support modules move into tests/it/ too. Crates with a single test file keep it. The test executables go from 150 to 52, and every crate runs the same number of tests as before. ui-egui perf keeps its own binary (own process): its frame-time budgets would charge each measurement for the other tests running beside it. What the move needs: - include_str!/include_bytes! and #[path] paths gain one "../". - ui-egui: one GPU lock in tests/it/main.rs replaces the five per-file ones (content, document_links, editing, fill_sign, view), so one process creates one wgpu device at a time. The drop-line test in ui.rs and the screenshot blocks behind PDFCRAFT_*_SHOT(S) and CRAFT_UI_DOCKING_DIR, which render without holding it, take it too. - pdfcraft winit_wayland_scroll: the vendored winit source it compiles names crate::event, so that shim lives in main.rs. - parity evidence names the new paths. * Name the one-binary test paths in docs and comments - READMEs, docs, ATTRIBUTION and source comments name tests/it/.rs. - The oracle README's example filters with --test it encryption::qpdf_agrees. - pdfcraft's GPU lock comment points at the UI tests' shared lock in crates/ui-egui/tests/it/main.rs. --- ATTRIBUTION.md | 6 +- ATTRIBUTION.toml | 6 +- apps/pdfcraft-cli/Cargo.toml | 2 +- apps/pdfcraft-cli/tests/{ => it}/audit.rs | 0 .../tests/{ => it}/automation_roots.rs | 0 apps/pdfcraft-cli/tests/{ => it}/batch.rs | 0 apps/pdfcraft-cli/tests/{ => it}/check.rs | 0 apps/pdfcraft-cli/tests/{ => it}/edit.rs | 0 apps/pdfcraft-cli/tests/{ => it}/extract.rs | 0 apps/pdfcraft-cli/tests/{ => it}/help.rs | 0 .../tests/{ => it}/info_standards.rs | 0 apps/pdfcraft-cli/tests/it/main.rs | 24 + .../tests/{ => it}/mcp_compact.rs | 0 apps/pdfcraft-cli/tests/{ => it}/options.rs | 0 .../tests/{ => it}/owner_password.rs | 0 apps/pdfcraft-cli/tests/{ => it}/render.rs | 0 .../tests/{ => it}/root_arguments.rs | 0 .../pdfcraft-cli/tests/{ => it}/run_report.rs | 0 apps/pdfcraft-cli/tests/{ => it}/run_root.rs | 0 apps/pdfcraft-cli/tests/{ => it}/split.rs | 0 apps/pdfcraft-cli/tests/{ => it}/stderr.rs | 0 apps/pdfcraft-cli/tests/{ => it}/stdout.rs | 0 apps/pdfcraft-cli/tests/{ => it}/text.rs | 0 .../pdfcraft-cli/tests/{ => it}/ui_control.rs | 0 .../tests/{ => it}/version_resource.rs | 0 apps/pdfcraft-cli/tests/{ => it}/xfa.rs | 0 apps/pdfcraft/src/main.rs | 2 +- apps/pdfcraft/tests/it/main.rs | 21 + .../{ => it}/winit_macos_emoji_picker.rs | 0 .../tests/{ => it}/winit_macos_ime_commit.rs | 0 .../tests/{ => it}/winit_macos_ime_discard.rs | 0 .../{ => it}/winit_macos_korean_first_key.rs | 0 .../{ => it}/winit_macos_korean_space.rs | 0 .../{ => it}/winit_macos_title_bar_drag.rs | 0 .../tests/{ => it}/winit_wayland_scroll.rs | 7 +- .../{ => it}/winit_wayland_text_input.rs | 0 .../winit_windows_hidden_maximized.rs | 0 .../{ => it}/winit_windows_ime_cursor.rs | 0 apps/pdfcraft/tests/{ => it}/winit_x11_dpi.rs | 0 .../tests/{ => it}/winit_x11_ime_focus.rs | 0 crates/automation/README.md | 2 +- .../automation/tests/{ => it}/automation.rs | 2 +- .../tests/{ => it}/automation_roots.rs | 0 crates/automation/tests/{ => it}/batch.rs | 0 .../tests/{ => it}/button_caption.rs | 0 crates/automation/tests/{ => it}/form_bulk.rs | 0 .../automation/tests/{ => it}/hostile_args.rs | 0 crates/automation/tests/it/main.rs | 9 + .../{ => it}/optimize_content_boundaries.rs | 0 crates/cos/tests/{ => it}/bounded_writer.rs | 0 crates/cos/tests/{ => it}/corpus.rs | 0 crates/cos/tests/{ => it}/encryption.rs | 0 crates/cos/tests/it/main.rs | 8 + crates/cos/tests/{ => it}/proptests.rs | 0 crates/cos/tests/{ => it}/shared_streams.rs | 0 crates/cos/tests/{ => it}/writer.rs | 0 crates/crypt/tests/it/main.rs | 4 + crates/crypt/tests/{ => it}/proptests.rs | 0 crates/crypt/tests/{ => it}/qpdf_literals.rs | 2 +- .../engine/tests/{ => it}/document_fonts.rs | 4 +- crates/engine/tests/{ => it}/form_rotation.rs | 0 crates/engine/tests/it/main.rs | 12 + .../{ => it}/marked_content_isolation.rs | 0 crates/engine/tests/{ => it}/ocr_edit.rs | 0 .../tests/{ => it}/private_image_resize.rs | 0 .../tests/{ => it}/private_text_glyphs.rs | 0 .../tests/{ => it}/support/font_fixture.rs | 0 crates/oracle/README.md | 8 +- .../render/tests/{ => it}/inline_image_ei.rs | 0 .../render/tests/{ => it}/jbig2_invariants.rs | 0 crates/render/tests/it/main.rs | 6 + .../tests/{ => it}/named_form_properties.rs | 0 crates/render/tests/{ => it}/search.rs | 0 crates/sign/src/ec512.rs | 2 +- crates/sign/tests/data/README.md | 4 +- crates/sign/tests/{ => it}/algorithms.rs | 0 crates/sign/tests/{ => it}/ber.rs | 0 crates/sign/tests/{ => it}/chain.rs | 2 +- crates/sign/tests/{ => it}/cms.rs | 2 +- crates/sign/tests/{ => it}/crypto.rs | 0 crates/sign/tests/it/main.rs | 9 + crates/sign/tests/{ => it}/pdf.rs | 10 +- crates/sign/tests/{ => it}/store_keys.rs | 0 crates/ui-egui/src/system_fonts.rs | 4 +- .../ui-egui/tests/{ => it}/accessibility.rs | 0 crates/ui-egui/tests/{ => it}/actions.rs | 0 .../ui-egui/tests/{ui_group => it}/animate.rs | 0 .../tests/{ => it}/bookmark_position.rs | 0 crates/ui-egui/tests/{ => it}/bookmarks.rs | 2 + .../tests/{ui_group => it}/bulk_fields.rs | 0 crates/ui-egui/tests/{ => it}/buttons.rs | 0 crates/ui-egui/tests/{ => it}/camera.rs | 0 .../tests/{ui_group => it}/column_select.rs | 0 crates/ui-egui/tests/{ => it}/combine_grid.rs | 0 crates/ui-egui/tests/{ => it}/commands.rs | 6 +- crates/ui-egui/tests/{ => it}/comments.rs | 3 +- crates/ui-egui/tests/{ => it}/compare.rs | 1 + crates/ui-egui/tests/{ => it}/content.rs | 13 +- crates/ui-egui/tests/{ => it}/control.rs | 6 + crates/ui-egui/tests/{ => it}/create.rs | 2 + crates/ui-egui/tests/{ => it}/date_format.rs | 0 .../ui-egui/tests/{ => it}/dialog_chrome.rs | 0 .../ui-egui/tests/{ => it}/document_links.rs | 9 +- crates/ui-egui/tests/{ => it}/editing.rs | 17 +- crates/ui-egui/tests/{ => it}/export.rs | 0 crates/ui-egui/tests/{ => it}/fill_sign.rs | 12 +- crates/ui-egui/tests/{ => it}/folders.rs | 0 crates/ui-egui/tests/{ => it}/fonts.rs | 48 +- crates/ui-egui/tests/{ => it}/forms.rs | 6 +- .../tests/{ui_group => it}/identity.rs | 0 crates/ui-egui/tests/{ => it}/javascript.rs | 0 .../tests/{ui_group => it}/layer_actions.rs | 0 .../ui-egui/tests/{ui_group => it}/links.rs | 0 crates/ui-egui/tests/it/main.rs | 74 ++ .../ui-egui/tests/{ui_group => it}/marks.rs | 0 .../tests/{ => it}/object_selection.rs | 1 + crates/ui-egui/tests/{ => it}/ocr.rs | 0 .../tests/{ui_group => it}/pageboxes.rs | 0 .../ui-egui/tests/{ => it}/panel_snapshots.rs | 4 +- .../tests/{ui_group => it}/pdf_links.rs | 0 crates/ui-egui/tests/{ => it}/pickers.rs | 2 +- .../tests/{ => it}/preferences_layout.rs | 0 crates/ui-egui/tests/{ => it}/prepare.rs | 2 +- crates/ui-egui/tests/{ => it}/print.rs | 4 +- .../ui-egui/tests/{ui_group => it}/protect.rs | 0 .../ui-egui/tests/{ => it}/reading_colors.rs | 0 crates/ui-egui/tests/{ => it}/recovery.rs | 2 +- .../ui-egui/tests/{ui_group => it}/redact.rs | 0 crates/ui-egui/tests/{ => it}/reload.rs | 0 crates/ui-egui/tests/{ => it}/signing.rs | 0 crates/ui-egui/tests/{ => it}/stamps.rs | 7 +- crates/ui-egui/tests/{ => it}/support/mod.rs | 0 .../tests/{ui_group => it}/text_select.rs | 0 .../ui-egui/tests/{ui_group => it}/theme.rs | 0 crates/ui-egui/tests/{ => it}/theme_button.rs | 0 crates/ui-egui/tests/{ => it}/trust_signer.rs | 0 crates/ui-egui/tests/{ => it}/ui.rs | 7 +- .../ui-egui/tests/{ui_group => it}/updates.rs | 0 crates/ui-egui/tests/{ => it}/view.rs | 12 +- crates/ui-egui/tests/{ => it}/viewer.rs | 2 + crates/ui-egui/tests/{ui_group => it}/xfa.rs | 0 crates/ui-egui/tests/ui_group/main.rs | 23 - docs/editing-multiple-objects.md | 4 +- docs/gaps.md | 2 +- docs/localization.md | 4 +- parity/acrobat-features.toml | 876 +++++++++--------- 146 files changed, 716 insertions(+), 593 deletions(-) rename apps/pdfcraft-cli/tests/{ => it}/audit.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/automation_roots.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/batch.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/check.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/edit.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/extract.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/help.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/info_standards.rs (100%) create mode 100644 apps/pdfcraft-cli/tests/it/main.rs rename apps/pdfcraft-cli/tests/{ => it}/mcp_compact.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/options.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/owner_password.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/render.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/root_arguments.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/run_report.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/run_root.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/split.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/stderr.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/stdout.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/text.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/ui_control.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/version_resource.rs (100%) rename apps/pdfcraft-cli/tests/{ => it}/xfa.rs (100%) create mode 100644 apps/pdfcraft/tests/it/main.rs rename apps/pdfcraft/tests/{ => it}/winit_macos_emoji_picker.rs (100%) rename apps/pdfcraft/tests/{ => it}/winit_macos_ime_commit.rs (100%) rename apps/pdfcraft/tests/{ => it}/winit_macos_ime_discard.rs (100%) rename apps/pdfcraft/tests/{ => it}/winit_macos_korean_first_key.rs (100%) rename apps/pdfcraft/tests/{ => it}/winit_macos_korean_space.rs (100%) rename apps/pdfcraft/tests/{ => it}/winit_macos_title_bar_drag.rs (100%) rename apps/pdfcraft/tests/{ => it}/winit_wayland_scroll.rs (93%) rename apps/pdfcraft/tests/{ => it}/winit_wayland_text_input.rs (100%) rename apps/pdfcraft/tests/{ => it}/winit_windows_hidden_maximized.rs (100%) rename apps/pdfcraft/tests/{ => it}/winit_windows_ime_cursor.rs (100%) rename apps/pdfcraft/tests/{ => it}/winit_x11_dpi.rs (100%) rename apps/pdfcraft/tests/{ => it}/winit_x11_ime_focus.rs (100%) rename crates/automation/tests/{ => it}/automation.rs (99%) rename crates/automation/tests/{ => it}/automation_roots.rs (100%) rename crates/automation/tests/{ => it}/batch.rs (100%) rename crates/automation/tests/{ => it}/button_caption.rs (100%) rename crates/automation/tests/{ => it}/form_bulk.rs (100%) rename crates/automation/tests/{ => it}/hostile_args.rs (100%) create mode 100644 crates/automation/tests/it/main.rs rename crates/automation/tests/{ => it}/optimize_content_boundaries.rs (100%) rename crates/cos/tests/{ => it}/bounded_writer.rs (100%) rename crates/cos/tests/{ => it}/corpus.rs (100%) rename crates/cos/tests/{ => it}/encryption.rs (100%) create mode 100644 crates/cos/tests/it/main.rs rename crates/cos/tests/{ => it}/proptests.rs (100%) rename crates/cos/tests/{ => it}/shared_streams.rs (100%) rename crates/cos/tests/{ => it}/writer.rs (100%) create mode 100644 crates/crypt/tests/it/main.rs rename crates/crypt/tests/{ => it}/proptests.rs (100%) rename crates/crypt/tests/{ => it}/qpdf_literals.rs (99%) rename crates/engine/tests/{ => it}/document_fonts.rs (99%) rename crates/engine/tests/{ => it}/form_rotation.rs (100%) create mode 100644 crates/engine/tests/it/main.rs rename crates/engine/tests/{ => it}/marked_content_isolation.rs (100%) rename crates/engine/tests/{ => it}/ocr_edit.rs (100%) rename crates/engine/tests/{ => it}/private_image_resize.rs (100%) rename crates/engine/tests/{ => it}/private_text_glyphs.rs (100%) rename crates/engine/tests/{ => it}/support/font_fixture.rs (100%) rename crates/render/tests/{ => it}/inline_image_ei.rs (100%) rename crates/render/tests/{ => it}/jbig2_invariants.rs (100%) create mode 100644 crates/render/tests/it/main.rs rename crates/render/tests/{ => it}/named_form_properties.rs (100%) rename crates/render/tests/{ => it}/search.rs (100%) rename crates/sign/tests/{ => it}/algorithms.rs (100%) rename crates/sign/tests/{ => it}/ber.rs (100%) rename crates/sign/tests/{ => it}/chain.rs (99%) rename crates/sign/tests/{ => it}/cms.rs (99%) rename crates/sign/tests/{ => it}/crypto.rs (100%) create mode 100644 crates/sign/tests/it/main.rs rename crates/sign/tests/{ => it}/pdf.rs (99%) rename crates/sign/tests/{ => it}/store_keys.rs (100%) rename crates/ui-egui/tests/{ => it}/accessibility.rs (100%) rename crates/ui-egui/tests/{ => it}/actions.rs (100%) rename crates/ui-egui/tests/{ui_group => it}/animate.rs (100%) rename crates/ui-egui/tests/{ => it}/bookmark_position.rs (100%) rename crates/ui-egui/tests/{ => it}/bookmarks.rs (99%) rename crates/ui-egui/tests/{ui_group => it}/bulk_fields.rs (100%) rename crates/ui-egui/tests/{ => it}/buttons.rs (100%) rename crates/ui-egui/tests/{ => it}/camera.rs (100%) rename crates/ui-egui/tests/{ui_group => it}/column_select.rs (100%) rename crates/ui-egui/tests/{ => it}/combine_grid.rs (100%) rename crates/ui-egui/tests/{ => it}/commands.rs (99%) rename crates/ui-egui/tests/{ => it}/comments.rs (99%) rename crates/ui-egui/tests/{ => it}/compare.rs (99%) rename crates/ui-egui/tests/{ => it}/content.rs (98%) rename crates/ui-egui/tests/{ => it}/control.rs (99%) rename crates/ui-egui/tests/{ => it}/create.rs (99%) rename crates/ui-egui/tests/{ => it}/date_format.rs (100%) rename crates/ui-egui/tests/{ => it}/dialog_chrome.rs (100%) rename crates/ui-egui/tests/{ => it}/document_links.rs (99%) rename crates/ui-egui/tests/{ => it}/editing.rs (99%) rename crates/ui-egui/tests/{ => it}/export.rs (100%) rename crates/ui-egui/tests/{ => it}/fill_sign.rs (99%) rename crates/ui-egui/tests/{ => it}/folders.rs (100%) rename crates/ui-egui/tests/{ => it}/fonts.rs (94%) rename crates/ui-egui/tests/{ => it}/forms.rs (99%) rename crates/ui-egui/tests/{ui_group => it}/identity.rs (100%) rename crates/ui-egui/tests/{ => it}/javascript.rs (100%) rename crates/ui-egui/tests/{ui_group => it}/layer_actions.rs (100%) rename crates/ui-egui/tests/{ui_group => it}/links.rs (100%) create mode 100644 crates/ui-egui/tests/it/main.rs rename crates/ui-egui/tests/{ui_group => it}/marks.rs (100%) rename crates/ui-egui/tests/{ => it}/object_selection.rs (99%) rename crates/ui-egui/tests/{ => it}/ocr.rs (100%) rename crates/ui-egui/tests/{ui_group => it}/pageboxes.rs (100%) rename crates/ui-egui/tests/{ => it}/panel_snapshots.rs (98%) rename crates/ui-egui/tests/{ui_group => it}/pdf_links.rs (100%) rename crates/ui-egui/tests/{ => it}/pickers.rs (99%) rename crates/ui-egui/tests/{ => it}/preferences_layout.rs (100%) rename crates/ui-egui/tests/{ => it}/prepare.rs (99%) rename crates/ui-egui/tests/{ => it}/print.rs (99%) rename crates/ui-egui/tests/{ui_group => it}/protect.rs (100%) rename crates/ui-egui/tests/{ => it}/reading_colors.rs (100%) rename crates/ui-egui/tests/{ => it}/recovery.rs (99%) rename crates/ui-egui/tests/{ui_group => it}/redact.rs (100%) rename crates/ui-egui/tests/{ => it}/reload.rs (100%) rename crates/ui-egui/tests/{ => it}/signing.rs (100%) rename crates/ui-egui/tests/{ => it}/stamps.rs (94%) rename crates/ui-egui/tests/{ => it}/support/mod.rs (100%) rename crates/ui-egui/tests/{ui_group => it}/text_select.rs (100%) rename crates/ui-egui/tests/{ui_group => it}/theme.rs (100%) rename crates/ui-egui/tests/{ => it}/theme_button.rs (100%) rename crates/ui-egui/tests/{ => it}/trust_signer.rs (100%) rename crates/ui-egui/tests/{ => it}/ui.rs (99%) rename crates/ui-egui/tests/{ui_group => it}/updates.rs (100%) rename crates/ui-egui/tests/{ => it}/view.rs (99%) rename crates/ui-egui/tests/{ => it}/viewer.rs (99%) rename crates/ui-egui/tests/{ui_group => it}/xfa.rs (100%) delete mode 100644 crates/ui-egui/tests/ui_group/main.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5b1695604..4efb31105 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -275,15 +275,15 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | 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 | 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 | 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 | -| `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/forms.rs, commands.rs) | +| `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 | 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 | 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 | -| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make | +| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make | | `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) | | `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py | | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | -| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format | +| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format | | `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 6c9c88a2e..7f70df3f8 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -3277,7 +3277,7 @@ source = "Contributor-original: hand-written PDF objects, no third-party content licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "document" -usage = "Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs)" +usage = "Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs)" sha256 = "c6f8b656e225101129aa36bd5467adc8dba16730915f19caca17f6a546d0b040" [[asset]] @@ -3310,7 +3310,7 @@ source = "Contributor-original: hand-written PDF objects; signature made with `o licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "document" -usage = "Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make" +usage = "Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make" sha256 = "8b91c3b4e39c4a31717a0dd0685bd7d0468671df32aee5b9818d44dc72c2bc3c" [[asset]] @@ -3403,7 +3403,7 @@ source = "Contributor-original: hand-written PDF objects; signature made with `o licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "document" -usage = "Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format" +usage = "Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format" sha256 = "b77bfb21166f4bd043597e45a1ea28b9d9f4ba6df0142b3a0fc8b7f8e0fc111b" [[asset]] diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index 991cbf98f..7b5f9a82f 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -24,7 +24,7 @@ pdfcraft-platform = { workspace = true } base64 = "0.22" [dev-dependencies] -# Decodes `render` output in tests/owner_password.rs; already a dependency of the engine. +# Decodes `render` output in tests/it/owner_password.rs; already a dependency of the engine. png = "0.18" [target.'cfg(unix)'.dependencies] diff --git a/apps/pdfcraft-cli/tests/audit.rs b/apps/pdfcraft-cli/tests/it/audit.rs similarity index 100% rename from apps/pdfcraft-cli/tests/audit.rs rename to apps/pdfcraft-cli/tests/it/audit.rs diff --git a/apps/pdfcraft-cli/tests/automation_roots.rs b/apps/pdfcraft-cli/tests/it/automation_roots.rs similarity index 100% rename from apps/pdfcraft-cli/tests/automation_roots.rs rename to apps/pdfcraft-cli/tests/it/automation_roots.rs diff --git a/apps/pdfcraft-cli/tests/batch.rs b/apps/pdfcraft-cli/tests/it/batch.rs similarity index 100% rename from apps/pdfcraft-cli/tests/batch.rs rename to apps/pdfcraft-cli/tests/it/batch.rs diff --git a/apps/pdfcraft-cli/tests/check.rs b/apps/pdfcraft-cli/tests/it/check.rs similarity index 100% rename from apps/pdfcraft-cli/tests/check.rs rename to apps/pdfcraft-cli/tests/it/check.rs diff --git a/apps/pdfcraft-cli/tests/edit.rs b/apps/pdfcraft-cli/tests/it/edit.rs similarity index 100% rename from apps/pdfcraft-cli/tests/edit.rs rename to apps/pdfcraft-cli/tests/it/edit.rs diff --git a/apps/pdfcraft-cli/tests/extract.rs b/apps/pdfcraft-cli/tests/it/extract.rs similarity index 100% rename from apps/pdfcraft-cli/tests/extract.rs rename to apps/pdfcraft-cli/tests/it/extract.rs diff --git a/apps/pdfcraft-cli/tests/help.rs b/apps/pdfcraft-cli/tests/it/help.rs similarity index 100% rename from apps/pdfcraft-cli/tests/help.rs rename to apps/pdfcraft-cli/tests/it/help.rs diff --git a/apps/pdfcraft-cli/tests/info_standards.rs b/apps/pdfcraft-cli/tests/it/info_standards.rs similarity index 100% rename from apps/pdfcraft-cli/tests/info_standards.rs rename to apps/pdfcraft-cli/tests/it/info_standards.rs diff --git a/apps/pdfcraft-cli/tests/it/main.rs b/apps/pdfcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..f0b87c97a --- /dev/null +++ b/apps/pdfcraft-cli/tests/it/main.rs @@ -0,0 +1,24 @@ +//! The crate's integration tests, linked into one test binary. + +mod audit; +mod automation_roots; +mod batch; +mod check; +mod edit; +mod extract; +mod help; +mod info_standards; +mod mcp_compact; +mod options; +mod owner_password; +mod render; +mod root_arguments; +mod run_report; +mod run_root; +mod split; +mod stderr; +mod stdout; +mod text; +mod ui_control; +mod version_resource; +mod xfa; diff --git a/apps/pdfcraft-cli/tests/mcp_compact.rs b/apps/pdfcraft-cli/tests/it/mcp_compact.rs similarity index 100% rename from apps/pdfcraft-cli/tests/mcp_compact.rs rename to apps/pdfcraft-cli/tests/it/mcp_compact.rs diff --git a/apps/pdfcraft-cli/tests/options.rs b/apps/pdfcraft-cli/tests/it/options.rs similarity index 100% rename from apps/pdfcraft-cli/tests/options.rs rename to apps/pdfcraft-cli/tests/it/options.rs diff --git a/apps/pdfcraft-cli/tests/owner_password.rs b/apps/pdfcraft-cli/tests/it/owner_password.rs similarity index 100% rename from apps/pdfcraft-cli/tests/owner_password.rs rename to apps/pdfcraft-cli/tests/it/owner_password.rs diff --git a/apps/pdfcraft-cli/tests/render.rs b/apps/pdfcraft-cli/tests/it/render.rs similarity index 100% rename from apps/pdfcraft-cli/tests/render.rs rename to apps/pdfcraft-cli/tests/it/render.rs diff --git a/apps/pdfcraft-cli/tests/root_arguments.rs b/apps/pdfcraft-cli/tests/it/root_arguments.rs similarity index 100% rename from apps/pdfcraft-cli/tests/root_arguments.rs rename to apps/pdfcraft-cli/tests/it/root_arguments.rs diff --git a/apps/pdfcraft-cli/tests/run_report.rs b/apps/pdfcraft-cli/tests/it/run_report.rs similarity index 100% rename from apps/pdfcraft-cli/tests/run_report.rs rename to apps/pdfcraft-cli/tests/it/run_report.rs diff --git a/apps/pdfcraft-cli/tests/run_root.rs b/apps/pdfcraft-cli/tests/it/run_root.rs similarity index 100% rename from apps/pdfcraft-cli/tests/run_root.rs rename to apps/pdfcraft-cli/tests/it/run_root.rs diff --git a/apps/pdfcraft-cli/tests/split.rs b/apps/pdfcraft-cli/tests/it/split.rs similarity index 100% rename from apps/pdfcraft-cli/tests/split.rs rename to apps/pdfcraft-cli/tests/it/split.rs diff --git a/apps/pdfcraft-cli/tests/stderr.rs b/apps/pdfcraft-cli/tests/it/stderr.rs similarity index 100% rename from apps/pdfcraft-cli/tests/stderr.rs rename to apps/pdfcraft-cli/tests/it/stderr.rs diff --git a/apps/pdfcraft-cli/tests/stdout.rs b/apps/pdfcraft-cli/tests/it/stdout.rs similarity index 100% rename from apps/pdfcraft-cli/tests/stdout.rs rename to apps/pdfcraft-cli/tests/it/stdout.rs diff --git a/apps/pdfcraft-cli/tests/text.rs b/apps/pdfcraft-cli/tests/it/text.rs similarity index 100% rename from apps/pdfcraft-cli/tests/text.rs rename to apps/pdfcraft-cli/tests/it/text.rs diff --git a/apps/pdfcraft-cli/tests/ui_control.rs b/apps/pdfcraft-cli/tests/it/ui_control.rs similarity index 100% rename from apps/pdfcraft-cli/tests/ui_control.rs rename to apps/pdfcraft-cli/tests/it/ui_control.rs diff --git a/apps/pdfcraft-cli/tests/version_resource.rs b/apps/pdfcraft-cli/tests/it/version_resource.rs similarity index 100% rename from apps/pdfcraft-cli/tests/version_resource.rs rename to apps/pdfcraft-cli/tests/it/version_resource.rs diff --git a/apps/pdfcraft-cli/tests/xfa.rs b/apps/pdfcraft-cli/tests/it/xfa.rs similarity index 100% rename from apps/pdfcraft-cli/tests/xfa.rs rename to apps/pdfcraft-cli/tests/it/xfa.rs diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 83923fbe5..1a36d64bf 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -780,7 +780,7 @@ mod tests { /// graphics libraries at a time. The test threads otherwise run them together, and on /// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs: /// the first device was still being set up when the second test began its own. The UI - /// tests take turns the same way (`crates/ui-egui/tests/view.rs`). Hold it for the whole + /// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole /// test (declared before the instance, so it's released after the device is dropped). static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); diff --git a/apps/pdfcraft/tests/it/main.rs b/apps/pdfcraft/tests/it/main.rs new file mode 100644 index 000000000..bc05099a5 --- /dev/null +++ b/apps/pdfcraft/tests/it/main.rs @@ -0,0 +1,21 @@ +//! The crate's integration tests, linked into one test binary. + +// Compile winit's scroll policy against its real event type, without a compositor. The vendored +// source (`winit_wayland_scroll`) names it as `crate::event`. +#[cfg(target_os = "linux")] +mod event { + pub use winit::event::TouchPhase; +} + +mod winit_macos_emoji_picker; +mod winit_macos_ime_commit; +mod winit_macos_ime_discard; +mod winit_macos_korean_first_key; +mod winit_macos_korean_space; +mod winit_macos_title_bar_drag; +mod winit_wayland_scroll; +mod winit_wayland_text_input; +mod winit_windows_hidden_maximized; +mod winit_windows_ime_cursor; +mod winit_x11_dpi; +mod winit_x11_ime_focus; diff --git a/apps/pdfcraft/tests/winit_macos_emoji_picker.rs b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs similarity index 100% rename from apps/pdfcraft/tests/winit_macos_emoji_picker.rs rename to apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs diff --git a/apps/pdfcraft/tests/winit_macos_ime_commit.rs b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs similarity index 100% rename from apps/pdfcraft/tests/winit_macos_ime_commit.rs rename to apps/pdfcraft/tests/it/winit_macos_ime_commit.rs diff --git a/apps/pdfcraft/tests/winit_macos_ime_discard.rs b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs similarity index 100% rename from apps/pdfcraft/tests/winit_macos_ime_discard.rs rename to apps/pdfcraft/tests/it/winit_macos_ime_discard.rs diff --git a/apps/pdfcraft/tests/winit_macos_korean_first_key.rs b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs similarity index 100% rename from apps/pdfcraft/tests/winit_macos_korean_first_key.rs rename to apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs diff --git a/apps/pdfcraft/tests/winit_macos_korean_space.rs b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs similarity index 100% rename from apps/pdfcraft/tests/winit_macos_korean_space.rs rename to apps/pdfcraft/tests/it/winit_macos_korean_space.rs diff --git a/apps/pdfcraft/tests/winit_macos_title_bar_drag.rs b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs similarity index 100% rename from apps/pdfcraft/tests/winit_macos_title_bar_drag.rs rename to apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs diff --git a/apps/pdfcraft/tests/winit_wayland_scroll.rs b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs similarity index 93% rename from apps/pdfcraft/tests/winit_wayland_scroll.rs rename to apps/pdfcraft/tests/it/winit_wayland_scroll.rs index ec76d2b4b..96686f7ec 100644 --- a/apps/pdfcraft/tests/winit_wayland_scroll.rs +++ b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs @@ -3,11 +3,8 @@ use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2}; use wayland_client::protocol::wl_pointer::AxisSource; -// Compile the production policy against winit's real event type, without a compositor. -mod event { - pub use winit::event::TouchPhase; -} -#[path = "../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"] +use crate::event; +#[path = "../../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"] mod scroll; fn egui_phase(phase: event::TouchPhase) -> TouchPhase { diff --git a/apps/pdfcraft/tests/winit_wayland_text_input.rs b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs similarity index 100% rename from apps/pdfcraft/tests/winit_wayland_text_input.rs rename to apps/pdfcraft/tests/it/winit_wayland_text_input.rs diff --git a/apps/pdfcraft/tests/winit_windows_hidden_maximized.rs b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs similarity index 100% rename from apps/pdfcraft/tests/winit_windows_hidden_maximized.rs rename to apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs diff --git a/apps/pdfcraft/tests/winit_windows_ime_cursor.rs b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs similarity index 100% rename from apps/pdfcraft/tests/winit_windows_ime_cursor.rs rename to apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs diff --git a/apps/pdfcraft/tests/winit_x11_dpi.rs b/apps/pdfcraft/tests/it/winit_x11_dpi.rs similarity index 100% rename from apps/pdfcraft/tests/winit_x11_dpi.rs rename to apps/pdfcraft/tests/it/winit_x11_dpi.rs diff --git a/apps/pdfcraft/tests/winit_x11_ime_focus.rs b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs similarity index 100% rename from apps/pdfcraft/tests/winit_x11_ime_focus.rs rename to apps/pdfcraft/tests/it/winit_x11_ime_focus.rs diff --git a/crates/automation/README.md b/crates/automation/README.md index 87111b2ea..7eb8ca119 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -128,7 +128,7 @@ claude mcp add pdfcraft -- pdfcraft-cli mcp --root 1. Add the engine capability first, with its tests (the tool is a thin adapter). 2. Add a `ToolDef` in `src/tools.rs`: a precise description, the schema, `ro()`/`destructive()`, and `cmd()` if a registry command exists. 3. Handle it in `Automation::call`, validating pages and positions with the existing helpers. -4. Add an end-to-end test in `tests/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links. +4. Add an end-to-end test in `tests/it/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links. ## Moving several page objects diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/it/automation.rs similarity index 99% rename from crates/automation/tests/automation.rs rename to crates/automation/tests/it/automation.rs index bf6169f42..0ebf3a9bc 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -539,7 +539,7 @@ fn tool_table_is_well_formed() { #[test] fn explicit_font_and_disjoint_ranges_save_reopen_through_tools() { let dir = workdir("font-ranges"); - std::fs::write(dir.join("inter.ttf"), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap(); + std::fs::write(dir.join("inter.ttf"), include_bytes!("../../../../assets/fonts/Inter-Regular.ttf")).unwrap(); let mut a = auto(&dir); std::fs::write(dir.join("broken.otf"), b"not a font").unwrap(); let found = ok(&mut a, "text_font_files", json!({})); diff --git a/crates/automation/tests/automation_roots.rs b/crates/automation/tests/it/automation_roots.rs similarity index 100% rename from crates/automation/tests/automation_roots.rs rename to crates/automation/tests/it/automation_roots.rs diff --git a/crates/automation/tests/batch.rs b/crates/automation/tests/it/batch.rs similarity index 100% rename from crates/automation/tests/batch.rs rename to crates/automation/tests/it/batch.rs diff --git a/crates/automation/tests/button_caption.rs b/crates/automation/tests/it/button_caption.rs similarity index 100% rename from crates/automation/tests/button_caption.rs rename to crates/automation/tests/it/button_caption.rs diff --git a/crates/automation/tests/form_bulk.rs b/crates/automation/tests/it/form_bulk.rs similarity index 100% rename from crates/automation/tests/form_bulk.rs rename to crates/automation/tests/it/form_bulk.rs diff --git a/crates/automation/tests/hostile_args.rs b/crates/automation/tests/it/hostile_args.rs similarity index 100% rename from crates/automation/tests/hostile_args.rs rename to crates/automation/tests/it/hostile_args.rs diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs new file mode 100644 index 000000000..8bb3c7781 --- /dev/null +++ b/crates/automation/tests/it/main.rs @@ -0,0 +1,9 @@ +//! The crate's integration tests, linked into one test binary. + +mod automation; +mod automation_roots; +mod batch; +mod button_caption; +mod form_bulk; +mod hostile_args; +mod optimize_content_boundaries; diff --git a/crates/automation/tests/optimize_content_boundaries.rs b/crates/automation/tests/it/optimize_content_boundaries.rs similarity index 100% rename from crates/automation/tests/optimize_content_boundaries.rs rename to crates/automation/tests/it/optimize_content_boundaries.rs diff --git a/crates/cos/tests/bounded_writer.rs b/crates/cos/tests/it/bounded_writer.rs similarity index 100% rename from crates/cos/tests/bounded_writer.rs rename to crates/cos/tests/it/bounded_writer.rs diff --git a/crates/cos/tests/corpus.rs b/crates/cos/tests/it/corpus.rs similarity index 100% rename from crates/cos/tests/corpus.rs rename to crates/cos/tests/it/corpus.rs diff --git a/crates/cos/tests/encryption.rs b/crates/cos/tests/it/encryption.rs similarity index 100% rename from crates/cos/tests/encryption.rs rename to crates/cos/tests/it/encryption.rs diff --git a/crates/cos/tests/it/main.rs b/crates/cos/tests/it/main.rs new file mode 100644 index 000000000..52cf05d6c --- /dev/null +++ b/crates/cos/tests/it/main.rs @@ -0,0 +1,8 @@ +//! The crate's integration tests, linked into one test binary. + +mod bounded_writer; +mod corpus; +mod encryption; +mod proptests; +mod shared_streams; +mod writer; diff --git a/crates/cos/tests/proptests.rs b/crates/cos/tests/it/proptests.rs similarity index 100% rename from crates/cos/tests/proptests.rs rename to crates/cos/tests/it/proptests.rs diff --git a/crates/cos/tests/shared_streams.rs b/crates/cos/tests/it/shared_streams.rs similarity index 100% rename from crates/cos/tests/shared_streams.rs rename to crates/cos/tests/it/shared_streams.rs diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/it/writer.rs similarity index 100% rename from crates/cos/tests/writer.rs rename to crates/cos/tests/it/writer.rs diff --git a/crates/crypt/tests/it/main.rs b/crates/crypt/tests/it/main.rs new file mode 100644 index 000000000..8b39bf195 --- /dev/null +++ b/crates/crypt/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The crate's integration tests, linked into one test binary. + +mod proptests; +mod qpdf_literals; diff --git a/crates/crypt/tests/proptests.rs b/crates/crypt/tests/it/proptests.rs similarity index 100% rename from crates/crypt/tests/proptests.rs rename to crates/crypt/tests/it/proptests.rs diff --git a/crates/crypt/tests/qpdf_literals.rs b/crates/crypt/tests/it/qpdf_literals.rs similarity index 99% rename from crates/crypt/tests/qpdf_literals.rs rename to crates/crypt/tests/it/qpdf_literals.rs index 8957063ac..f34464d98 100644 --- a/crates/crypt/tests/qpdf_literals.rs +++ b/crates/crypt/tests/it/qpdf_literals.rs @@ -278,7 +278,7 @@ fn metadata_flag_from_outside_changes_the_file_key_it_certifies() { // `/EncryptMetadata` false folds four set bytes into a revision 4 key (Algorithm 2, step f) and // changes what revision 6 stores in `/U`. qpdf wrote both variants of each, so an implementation // that ignored the flag would give identical keys. Internally this is no longer untested - - // `tests/proptests.rs` from #2358 varies the flag and asserts the keys differ - but internal tests + // `tests/it/proptests.rs` from #2358 varies the flag and asserts the keys differ - but internal tests // still share our derivation, so what these rows add is a second implementation's own numbers. for (enc_label, plain_label) in [("r4-aes128-meta-enc", "r4-aes128-meta-plain"), ("r6-aes256-meta-enc", "r6-aes256-meta-plain")] { let a = CASES.iter().find(|c| c.label == enc_label).expect("encrypted-metadata fixture present"); diff --git a/crates/engine/tests/document_fonts.rs b/crates/engine/tests/it/document_fonts.rs similarity index 99% rename from crates/engine/tests/document_fonts.rs rename to crates/engine/tests/it/document_fonts.rs index ebeff24ae..e4212f4a3 100644 --- a/crates/engine/tests/document_fonts.rs +++ b/crates/engine/tests/it/document_fonts.rs @@ -1,7 +1,5 @@ //! Document font selection and contextual glyph save/reopen acceptance on synthetic PDFs. -mod support { - pub mod font_fixture; -} +use crate::support; use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; use pdfcraft_engine::{BlockStyle, Edit, Session}; use pdfcraft_render::{PageRenderer, RenderRequest}; diff --git a/crates/engine/tests/form_rotation.rs b/crates/engine/tests/it/form_rotation.rs similarity index 100% rename from crates/engine/tests/form_rotation.rs rename to crates/engine/tests/it/form_rotation.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 000000000..15e1a8548 --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,12 @@ +//! The crate's integration tests, linked into one test binary. + +mod support { + pub mod font_fixture; +} + +mod document_fonts; +mod form_rotation; +mod marked_content_isolation; +mod ocr_edit; +mod private_image_resize; +mod private_text_glyphs; diff --git a/crates/engine/tests/marked_content_isolation.rs b/crates/engine/tests/it/marked_content_isolation.rs similarity index 100% rename from crates/engine/tests/marked_content_isolation.rs rename to crates/engine/tests/it/marked_content_isolation.rs diff --git a/crates/engine/tests/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs similarity index 100% rename from crates/engine/tests/ocr_edit.rs rename to crates/engine/tests/it/ocr_edit.rs diff --git a/crates/engine/tests/private_image_resize.rs b/crates/engine/tests/it/private_image_resize.rs similarity index 100% rename from crates/engine/tests/private_image_resize.rs rename to crates/engine/tests/it/private_image_resize.rs diff --git a/crates/engine/tests/private_text_glyphs.rs b/crates/engine/tests/it/private_text_glyphs.rs similarity index 100% rename from crates/engine/tests/private_text_glyphs.rs rename to crates/engine/tests/it/private_text_glyphs.rs diff --git a/crates/engine/tests/support/font_fixture.rs b/crates/engine/tests/it/support/font_fixture.rs similarity index 100% rename from crates/engine/tests/support/font_fixture.rs rename to crates/engine/tests/it/support/font_fixture.rs diff --git a/crates/oracle/README.md b/crates/oracle/README.md index 04eea193a..01a53360d 100644 --- a/crates/oracle/README.md +++ b/crates/oracle/README.md @@ -15,7 +15,7 @@ Every test that wanted an oracle used to probe for it by hand and decide on its it was missing. The common choice — print a line and return — reports as a **pass**, so a suite with no oracles installed looked exactly like one in which every oracle agreed. -That was not hypothetical. No CI runner installed `qpdf`, so `cos/tests/encryption.rs::qpdf_agrees` +That was not hypothetical. No CI runner installed `qpdf`, so `cos/tests/it/encryption.rs::qpdf_agrees` had never executed on a runner, while `README.md` said saved output was "independently checked with qpdf". For R2–R4 that test is the *only* check of owner-password authentication (Algorithm 7) that does not go through our own code. @@ -50,10 +50,10 @@ Each returns `Result`. `Oracle::command()` hands back a fresh `C install the tools, so a skip notice there becomes a red build. ``` -$ cargo test -p pdfcraft-cos --test encryption qpdf_agrees +$ cargo test -p pdfcraft-cos --test it encryption::qpdf_agrees test result: ok. 1 passed # qpdf absent: skipped, with a notice on stderr -$ PDFCRAFT_ORACLES_REQUIRED=1 cargo test -p pdfcraft-cos --test encryption qpdf_agrees +$ PDFCRAFT_ORACLES_REQUIRED=1 cargo test -p pdfcraft-cos --test it encryption::qpdf_agrees oracle `qpdf` is required but is not on PATH (program not found): PDFCRAFT_ORACLES_REQUIRED is set, so this is a failure rather than a skip (apt install qpdf / brew install qpdf) test result: FAILED. 1 failed @@ -90,7 +90,7 @@ appearance we do draw. **Poppler is not a usable oracle for JBIG2.** The 96 `bitmap-*.pdf` corpus fixtures encode one picture through every corner of the format, so a correct decoder renders them all identically. We render 1 distinct image across all 96; Poppler renders 8, drawing a blank page for seven of them. -`crates/render/tests/jbig2_invariants.rs` pins that invariant, and needs no oracle to do it. +`crates/render/tests/it/jbig2_invariants.rs` pins that invariant, and needs no oracle to do it. ## Comparing text diff --git a/crates/render/tests/inline_image_ei.rs b/crates/render/tests/it/inline_image_ei.rs similarity index 100% rename from crates/render/tests/inline_image_ei.rs rename to crates/render/tests/it/inline_image_ei.rs diff --git a/crates/render/tests/jbig2_invariants.rs b/crates/render/tests/it/jbig2_invariants.rs similarity index 100% rename from crates/render/tests/jbig2_invariants.rs rename to crates/render/tests/it/jbig2_invariants.rs diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs new file mode 100644 index 000000000..944fcd09b --- /dev/null +++ b/crates/render/tests/it/main.rs @@ -0,0 +1,6 @@ +//! The crate's integration tests, linked into one test binary. + +mod inline_image_ei; +mod jbig2_invariants; +mod named_form_properties; +mod search; diff --git a/crates/render/tests/named_form_properties.rs b/crates/render/tests/it/named_form_properties.rs similarity index 100% rename from crates/render/tests/named_form_properties.rs rename to crates/render/tests/it/named_form_properties.rs diff --git a/crates/render/tests/search.rs b/crates/render/tests/it/search.rs similarity index 100% rename from crates/render/tests/search.rs rename to crates/render/tests/it/search.rs diff --git a/crates/sign/src/ec512.rs b/crates/sign/src/ec512.rs index 53c73e541..974eb4e08 100644 --- a/crates/sign/src/ec512.rs +++ b/crates/sign/src/ec512.rs @@ -2,7 +2,7 @@ //! //! Verification only, on public data, so plain affine arithmetic over `crypto-bigint` is enough: //! no secret is involved, and a verification takes a few milliseconds. Checked against -//! signatures made by OpenSSL (`tests/crypto.rs`). +//! signatures made by OpenSSL (`tests/it/crypto.rs`). use rsa::BoxedUint; diff --git a/crates/sign/tests/data/README.md b/crates/sign/tests/data/README.md index 5685d0e35..e47bfe575 100644 --- a/crates/sign/tests/data/README.md +++ b/crates/sign/tests/data/README.md @@ -45,14 +45,14 @@ A synthetic one-page PDF signed the legacy way (`/SubFilter /adbe.x509.rsa_sha1` the key of `rsa-aes.p12`. Built by writing the objects with a fixed-width `/ByteRange` and zero-filled `/Contents`, patching the byte range, then `openssl dgst -sha1 -sign rsa.key` over the two ranges. -### RSA signature variants (hex constants in `tests/crypto.rs`) +### RSA signature variants (hex constants in `tests/it/crypto.rs`) Over SHA-256("hello") with the key of `rsa-aes.p12`: `openssl dgst -sha256 -sign rsa.key` (standard DigestInfo); `openssl pkeyutl -sign -pkeyopt rsa_padding_mode:pkcs1` over a hand-built DigestInfo without the NULL parameter, and over the bare digest; `openssl dgst -sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:0 -sign rsa.key`. -### Other algorithms (hex constants in `tests/algorithms.rs`) +### Other algorithms (hex constants in `tests/it/algorithms.rs`) All sign the message `hello` with throw-away keys, OpenSSL 3.6. RSA ones use the key of `rsa-aes.p12`. diff --git a/crates/sign/tests/algorithms.rs b/crates/sign/tests/it/algorithms.rs similarity index 100% rename from crates/sign/tests/algorithms.rs rename to crates/sign/tests/it/algorithms.rs diff --git a/crates/sign/tests/ber.rs b/crates/sign/tests/it/ber.rs similarity index 100% rename from crates/sign/tests/ber.rs rename to crates/sign/tests/it/ber.rs diff --git a/crates/sign/tests/chain.rs b/crates/sign/tests/it/chain.rs similarity index 99% rename from crates/sign/tests/chain.rs rename to crates/sign/tests/it/chain.rs index 59834104e..5defd859d 100644 --- a/crates/sign/tests/chain.rs +++ b/crates/sign/tests/it/chain.rs @@ -192,7 +192,7 @@ fn verdict(leaf: Party, embedded: Vec, anchor: &Certificate) -> (St let trust = TrustStore { certs: vec![anchor.clone()] }; let s = signatures(&Document::open(Arc::new(signed.clone())).unwrap(), &signed, &trust).into_iter().rfind(|s| s.signed).unwrap(); // The details as they read in English; what an interface says in another language is checked in - // `tests/pdf.rs`. + // `tests/it/pdf.rs`. (s.status, s.details.iter().map(|d| d.message.clone()).collect()) } diff --git a/crates/sign/tests/cms.rs b/crates/sign/tests/it/cms.rs similarity index 99% rename from crates/sign/tests/cms.rs rename to crates/sign/tests/it/cms.rs index dec7faced..f029dc181 100644 --- a/crates/sign/tests/cms.rs +++ b/crates/sign/tests/it/cms.rs @@ -105,7 +105,7 @@ fn the_signed_content_type_is_the_type_of_the_encapsulated_content() { assert!(why.contains("not the encapsulated content type"), "{why}"); // Without a content-type attribute there is nothing to compare here. The type is kept, and a - // PDF signature's validation refuses one that is not data (tests/pdf.rs). + // PDF signature's validation refuses one that is not data (tests/it/pdf.rs). let sd = SignedData::parse(&signed_data(&id, SIGNED_DATA, std::slice::from_ref(&message_digest))).unwrap(); assert_eq!(sd.econtent_type, SIGNED_DATA); } diff --git a/crates/sign/tests/crypto.rs b/crates/sign/tests/it/crypto.rs similarity index 100% rename from crates/sign/tests/crypto.rs rename to crates/sign/tests/it/crypto.rs diff --git a/crates/sign/tests/it/main.rs b/crates/sign/tests/it/main.rs new file mode 100644 index 000000000..7b8790850 --- /dev/null +++ b/crates/sign/tests/it/main.rs @@ -0,0 +1,9 @@ +//! The crate's integration tests, linked into one test binary. + +mod algorithms; +mod ber; +mod chain; +mod cms; +mod crypto; +mod pdf; +mod store_keys; diff --git a/crates/sign/tests/pdf.rs b/crates/sign/tests/it/pdf.rs similarity index 99% rename from crates/sign/tests/pdf.rs rename to crates/sign/tests/it/pdf.rs index be7ccf4b6..e9db0b570 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -2182,11 +2182,15 @@ fn appearance_label_outside_helvetica_is_refused_beside_an_embedded_font_name() /// the files. An interface then has these to translate and only these. #[test] fn wordings_are_complete() { - let source = include_str!("../src/pdf.rs"); + let source = include_str!("../../src/pdf.rs"); // The modules below `pdf` word whole sentences too: how a signature value had to be read, and // what a certificate does not allow. - let elsewhere = - [include_str!("../src/x509.rs"), include_str!("../src/cms.rs"), include_str!("../src/keys.rs"), include_str!("../src/rsa_pad.rs")]; + let elsewhere = [ + include_str!("../../src/x509.rs"), + include_str!("../../src/cms.rs"), + include_str!("../../src/keys.rs"), + include_str!("../../src/rsa_pad.rs"), + ]; /// A sentence said to the user, as opposed to an OID, a name or a tag: it reads as English. fn is_sentence(s: &str) -> bool { s.len() > 24 && s.ends_with('.') && s.contains(' ') && s.starts_with(|c: char| c.is_ascii_uppercase()) diff --git a/crates/sign/tests/store_keys.rs b/crates/sign/tests/it/store_keys.rs similarity index 100% rename from crates/sign/tests/store_keys.rs rename to crates/sign/tests/it/store_keys.rs diff --git a/crates/ui-egui/src/system_fonts.rs b/crates/ui-egui/src/system_fonts.rs index 0d43e98ec..e2c6a0dbd 100644 --- a/crates/ui-egui/src/system_fonts.rs +++ b/crates/ui-egui/src/system_fonts.rs @@ -499,7 +499,7 @@ mod tests { /// `script_fallback` honours the documented installed-font switch, under which it returns /// nothing before order is ever consulted — so both loader tests and the macOS oracle skip when - /// it is off, the way `tests/fonts.rs` skips for the same reason. Without this the suite goes red in exactly the + /// it is off, the way `tests/it/fonts.rs` skips for the same reason. Without this the suite goes red in exactly the /// mode `cargo xtask screenshots` uses. fn installed_faces_switched_off() -> bool { std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") @@ -527,7 +527,7 @@ mod tests { #[test] fn a_script_fallback_is_the_first_candidate_with_the_probe() { // This one calls `script_fallback` too, so it fails under the documented switch that - // published screenshots use — skip with a reason, as tests/fonts.rs does. + // published screenshots use — skip with a reason, as tests/it/fonts.rs does. if installed_faces_switched_off() { eprintln!("skipping: PDFCRAFT_SYSTEM_FONTS=0 turns the loader off"); return; diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/it/accessibility.rs similarity index 100% rename from crates/ui-egui/tests/accessibility.rs rename to crates/ui-egui/tests/it/accessibility.rs diff --git a/crates/ui-egui/tests/actions.rs b/crates/ui-egui/tests/it/actions.rs similarity index 100% rename from crates/ui-egui/tests/actions.rs rename to crates/ui-egui/tests/it/actions.rs diff --git a/crates/ui-egui/tests/ui_group/animate.rs b/crates/ui-egui/tests/it/animate.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/animate.rs rename to crates/ui-egui/tests/it/animate.rs diff --git a/crates/ui-egui/tests/bookmark_position.rs b/crates/ui-egui/tests/it/bookmark_position.rs similarity index 100% rename from crates/ui-egui/tests/bookmark_position.rs rename to crates/ui-egui/tests/it/bookmark_position.rs diff --git a/crates/ui-egui/tests/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs similarity index 99% rename from crates/ui-egui/tests/bookmarks.rs rename to crates/ui-egui/tests/it/bookmarks.rs index f180799a5..443359988 100644 --- a/crates/ui-egui/tests/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -181,6 +181,7 @@ fn search_finds_collapsed_bookmarks_and_keeps_original_paths() { assert!(h.query_by_label("Other chapter").is_none()); assert_eq!(h.state().session.save_full_bytes(id).unwrap(), before, "search doesn't edit the PDF"); if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") { + let _gpu = crate::gpu(); h.render().unwrap().save(std::path::Path::new(&dir).join("filtered-bookmarks.png")).unwrap(); } menu(&mut h, "Résumé results", "Delete"); @@ -243,6 +244,7 @@ trailer << /Root 1 0 R >> h.get_by_label("Method"); assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("New bookmarks from structure")); if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") { + let _gpu = crate::gpu(); h.render().unwrap().save(std::path::Path::new(&dir).join("bookmarks-from-structure.png")).unwrap(); } } diff --git a/crates/ui-egui/tests/ui_group/bulk_fields.rs b/crates/ui-egui/tests/it/bulk_fields.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/bulk_fields.rs rename to crates/ui-egui/tests/it/bulk_fields.rs diff --git a/crates/ui-egui/tests/buttons.rs b/crates/ui-egui/tests/it/buttons.rs similarity index 100% rename from crates/ui-egui/tests/buttons.rs rename to crates/ui-egui/tests/it/buttons.rs diff --git a/crates/ui-egui/tests/camera.rs b/crates/ui-egui/tests/it/camera.rs similarity index 100% rename from crates/ui-egui/tests/camera.rs rename to crates/ui-egui/tests/it/camera.rs diff --git a/crates/ui-egui/tests/ui_group/column_select.rs b/crates/ui-egui/tests/it/column_select.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/column_select.rs rename to crates/ui-egui/tests/it/column_select.rs diff --git a/crates/ui-egui/tests/combine_grid.rs b/crates/ui-egui/tests/it/combine_grid.rs similarity index 100% rename from crates/ui-egui/tests/combine_grid.rs rename to crates/ui-egui/tests/it/combine_grid.rs diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/it/commands.rs similarity index 99% rename from crates/ui-egui/tests/commands.rs rename to crates/ui-egui/tests/it/commands.rs index 8a5f69954..6f1028861 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -1,7 +1,7 @@ //! The command registry drives menus, shortcuts and the palette: every registered command must //! be implemented, disabled commands must say why, and every surface must reach the same action. -mod support; +use crate::support; use egui::os::OperatingSystem; use egui::{Key, Modifiers}; @@ -71,14 +71,14 @@ const PICKERS: &[&str] = &[ #[test] fn every_registered_command_is_implemented() { for spec in COMMANDS { - // Page-text Copy needs a loaded view and egui context, covered by ui_group::text_select. + // Page-text Copy needs a loaded view and egui context, covered by text_select. if PICKERS.contains(&spec.id) || spec.id == "edit.copy" { continue; } let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); if spec.id.starts_with("form.") || spec.id == "comment.flatten" { - app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); } else if spec.id == "protect.unlock" { // Only a permissions password: opens restricted, and can be unlocked. let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(3))).unwrap(); diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/it/comments.rs similarity index 99% rename from crates/ui-egui/tests/comments.rs rename to crates/ui-egui/tests/it/comments.rs index e9943b35b..6194c7516 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -7,7 +7,7 @@ use egui_kittest::kittest::Queryable; use pdfcraft_render::Annotation; use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; -mod support; +use crate::support; /// Two 300×200 pages of Helvetica text. const TEXT_FIXTURE: &[u8] = b"%PDF-1.7 @@ -145,6 +145,7 @@ fn sidebar_comment_centres_the_annotation_at_high_zoom() { && rotation == 0 && let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + let _gpu = crate::gpu(); settle(&mut h); h.render().unwrap().save(format!("{dir}/comment-centred.png")).unwrap(); } diff --git a/crates/ui-egui/tests/compare.rs b/crates/ui-egui/tests/it/compare.rs similarity index 99% rename from crates/ui-egui/tests/compare.rs rename to crates/ui-egui/tests/it/compare.rs index ea93c8e3b..52acae545 100644 --- a/crates/ui-egui/tests/compare.rs +++ b/crates/ui-egui/tests/it/compare.rs @@ -164,6 +164,7 @@ fn compare_filters_hide_text_or_visual_changes() { #[test] fn compare_panel_screenshot() { let Ok(out) = std::env::var("PDFCRAFT_SHOT") else { return }; + let _gpu = crate::gpu(); let s = Session::new(); let v1 = s.create_from_text("t", "Delivery within five days. Returns accepted.").unwrap().to_vec(); let v2 = s.create_from_text("t", "Delivery within three days. Returns accepted for a week.").unwrap().to_vec(); diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/it/content.rs similarity index 98% rename from crates/ui-egui/tests/content.rs rename to crates/ui-egui/tests/it/content.rs index 9294ec267..515e3e04f 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -6,23 +6,14 @@ use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; -/// Tests that render pixels (`h.render()`) take this first, so only one wgpu device exists at a -/// time. Lavapipe crashes when another test creates its Vulkan instance meanwhile (segfault in -/// `vkCreateSampler` during a concurrent `vkCreateInstance`). Declared before the harness, so it -/// is released after the device is dropped. -static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -fn gpu() -> std::sync::MutexGuard<'static, ()> { - // A test that panicked while holding it leaves nothing to clean up. - GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) -} +use crate::gpu; fn harness() -> Harness<'static, PdfCraftApp> { harness_with_step_dt(0.25) } fn harness_with_step_dt(step_dt: f32) -> Harness<'static, PdfCraftApp> { - harness_bytes_with_step_dt(include_bytes!("data/form.pdf").to_vec(), step_dt) + harness_bytes_with_step_dt(include_bytes!("../data/form.pdf").to_vec(), step_dt) } fn harness_bytes(bytes: Vec) -> Harness<'static, PdfCraftApp> { diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/it/control.rs similarity index 99% rename from crates/ui-egui/tests/control.rs rename to crates/ui-egui/tests/it/control.rs index 9082c689c..54bfe2fa3 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -282,12 +282,14 @@ fn bookmark_titles_can_be_searched_over_control() { ok(&mut h, &c, "ui.click", json!({ "label": "Target chapter" })); assert_eq!(h.state().views[0].current, 3); if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") { + let _gpu = crate::gpu(); h.render().unwrap().save(format!("{dir}/control-filtered-bookmarks.png")).unwrap(); } ok(&mut h, &c, "ui.click", json!({ "label": "Clear" })); h.get_by_label("Background"); h.get_by_label("Target chapter"); if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") { + let _gpu = crate::gpu(); h.render().unwrap().save(format!("{dir}/control-cleared-bookmarks.png")).unwrap(); } } @@ -1453,6 +1455,7 @@ fn docking_renders_real_pdf_default_floating_and_redocked() { h.run_steps(5); h.state().docking.validate().unwrap(); if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { + let _gpu = crate::gpu(); std::fs::create_dir_all(&directory).unwrap(); h.render().unwrap().save(format!("{directory}/pdf-{width}-{scale}-{state}.png")).unwrap(); } @@ -1499,6 +1502,7 @@ fn home_and_combine_suppress_inspector_without_forgetting_layout_or_choice() { h.run_steps(4); assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").is_none()); if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { + let _gpu = crate::gpu(); std::fs::create_dir_all(&directory).unwrap(); let state = if combine { "combine" } else { "home" }; h.render().unwrap().save(format!("{directory}/availability-{state}.png")).unwrap(); @@ -1511,6 +1515,7 @@ fn home_and_combine_suppress_inspector_without_forgetting_layout_or_choice() { h.run_steps(4); h.get_by_label("This document has no bookmarks."); if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { + let _gpu = crate::gpu(); h.render().unwrap().save(format!("{directory}/availability-return.png")).unwrap(); } assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before); @@ -1810,6 +1815,7 @@ fn page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant() { h.run_steps(3); assert!(shown(&h), "bottom-left: shown"); if let Ok(dir) = std::env::var("PDFCRAFT_SHOT_DIR") { + let _gpu = crate::gpu(); h.render().unwrap().save(format!("{dir}/page-size-hint.png")).unwrap(); } h.event(egui::Event::PointerMoved(away)); diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/it/create.rs similarity index 99% rename from crates/ui-egui/tests/create.rs rename to crates/ui-egui/tests/it/create.rs index ce2a5f37e..711884f8a 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/it/create.rs @@ -25,6 +25,7 @@ fn image_import_dialog_chooses_dpi_and_cancels() { }); h.run_steps(3); if let Ok(path) = std::env::var("PDFCRAFT_IMAGE_IMPORT_SHOT") { + let _gpu = crate::gpu(); h.render().unwrap().save(path).unwrap(); } h.get_by_label("Use 72 DPI (one point per pixel)").click(); @@ -322,6 +323,7 @@ fn multiple_files_open_as_one_document_in_the_page_grid() { h.get_by_label_contains("left out: report.docx"); h.get_by_label("Insert a file before page 2"); if let Ok(path) = std::env::var("PDFCRAFT_CREATE_MULTIPLE_SHOT") { + let _gpu = crate::gpu(); h.run_steps(20); h.render().unwrap().save(path).unwrap(); } diff --git a/crates/ui-egui/tests/date_format.rs b/crates/ui-egui/tests/it/date_format.rs similarity index 100% rename from crates/ui-egui/tests/date_format.rs rename to crates/ui-egui/tests/it/date_format.rs diff --git a/crates/ui-egui/tests/dialog_chrome.rs b/crates/ui-egui/tests/it/dialog_chrome.rs similarity index 100% rename from crates/ui-egui/tests/dialog_chrome.rs rename to crates/ui-egui/tests/it/dialog_chrome.rs diff --git a/crates/ui-egui/tests/document_links.rs b/crates/ui-egui/tests/it/document_links.rs similarity index 99% rename from crates/ui-egui/tests/document_links.rs rename to crates/ui-egui/tests/it/document_links.rs index ab7987b94..8405446df 100644 --- a/crates/ui-egui/tests/document_links.rs +++ b/crates/ui-egui/tests/it/document_links.rs @@ -1,18 +1,13 @@ //! Addresses a document asks to open (links, button URI actions, `app.launchURL`) wait for the //! user's permission, and anything but a web or email address is refused (#90, #91). -mod support; +use crate::support; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::{LinkOrigin, PdfCraftApp, PendingLink}; -/// See `tests/view.rs`: WARP's shader JIT crashes when two devices compile at once on ARM64. -static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -fn gpu() -> std::sync::MutexGuard<'static, ()> { - GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) -} +use crate::gpu; const LINK_URL: &str = "https://example.org/from-a-link"; const BUTTON_URL: &str = "https://example.org/from-a-button"; diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/it/editing.rs similarity index 99% rename from crates/ui-egui/tests/editing.rs rename to crates/ui-egui/tests/it/editing.rs index e23ac966d..71ba9ccda 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -1,7 +1,7 @@ //! Headless UI tests for editing and saving: the organize toolbar, selection, undo/redo keys, //! save/save-as, the unsaved-changes prompt and editable document properties. -mod support; +use crate::support; use egui::accesskit::Role; use egui::{Key, Modifiers}; @@ -10,16 +10,7 @@ use egui_kittest::kittest::{NodeT, Queryable}; use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use pdfcraft_ui_egui::{CloseRequest, Dialog, OpenOutcome, PdfCraftApp}; -/// Tests that render pixels (`h.render()`) take this first, so only one wgpu device exists at a -/// time. Lavapipe crashes when another test creates its Vulkan instance meanwhile (segfault in -/// `vkCreateSampler` during a concurrent `vkCreateInstance`). Declared before the harness, so it -/// is released after the device is dropped. -static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -fn gpu() -> std::sync::MutexGuard<'static, ()> { - // A test that panicked while holding it leaves nothing to clean up. - GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) -} +use crate::gpu; /// An `n`-page document with a proper xref table; page `i` shows "Page i+1". fn fixture(n: usize) -> Vec { @@ -2389,7 +2380,7 @@ fn remove_security_asks_for_the_permissions_password_first() { #[test] fn a_secured_form_shows_its_form_notice_and_its_security() { // Changing and assembling withheld, filling allowed (as government forms are often set). - let bytes = encrypted(include_bytes!("data/form.pdf").to_vec(), pdfcraft_cos::Algorithm::Aes256, "", "owner", -1036); + let bytes = encrypted(include_bytes!("../data/form.pdf").to_vec(), pdfcraft_cos::Algorithm::Aes256, "", "owner", -1036); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); @@ -4845,7 +4836,7 @@ fn inline_editor_preserves_source_font_and_previews_live_bounds() { "BT /F1 18 Tf 20 220 Td (Stable source text) Tj ET BT /F1 12 Tf 20 194 Td (The second row stays here) Tj ET", ))) .unwrap(); - let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); let face = skrifa::FontRef::new(bytes).unwrap(); let metrics = face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); let glyphs = face.glyph_metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); diff --git a/crates/ui-egui/tests/export.rs b/crates/ui-egui/tests/it/export.rs similarity index 100% rename from crates/ui-egui/tests/export.rs rename to crates/ui-egui/tests/it/export.rs diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs similarity index 99% rename from crates/ui-egui/tests/fill_sign.rs rename to crates/ui-egui/tests/it/fill_sign.rs index 61fb5fe6d..88051a9e5 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -13,15 +13,7 @@ const FIXTURE: &[u8] = b"%PDF-1.7 trailer << /Root 1 0 R >> %%EOF"; -/// Tests that render pixels (`h.render()`) take this first, so only one wgpu device compiles -/// shaders at a time: WARP's ARM64 pixel-shader JIT crashes when two do (see `tests/view.rs`). -/// Declared before the harness, so it is released after the device is dropped. -static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -fn gpu() -> std::sync::MutexGuard<'static, ()> { - // A test that panicked while holding it leaves nothing to clean up. - GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) -} +use crate::gpu; fn harness() -> Harness<'static, PdfCraftApp> { harness_bytes(FIXTURE) @@ -1398,7 +1390,7 @@ fn lines_grow_and_shrink_with_their_handles_before_release() { /// doesn't take the press. Away from the signature, a click still fills the field. #[test] fn select_tool_picks_up_a_signature_over_a_form_field() { - let mut h = harness_bytes(include_bytes!("data/form.pdf")); + let mut h = harness_bytes(include_bytes!("../data/form.pdf")); h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); h.state_mut().execute("sign.fill.signature"); click(&mut h, 150.0, 270.0); diff --git a/crates/ui-egui/tests/folders.rs b/crates/ui-egui/tests/it/folders.rs similarity index 100% rename from crates/ui-egui/tests/folders.rs rename to crates/ui-egui/tests/it/folders.rs diff --git a/crates/ui-egui/tests/fonts.rs b/crates/ui-egui/tests/it/fonts.rs similarity index 94% rename from crates/ui-egui/tests/fonts.rs rename to crates/ui-egui/tests/it/fonts.rs index d311dfc52..b2e9ffeb2 100644 --- a/crates/ui-egui/tests/fonts.rs +++ b/crates/ui-egui/tests/it/fonts.rs @@ -292,8 +292,8 @@ fn chinese_ui_uses_an_installed_chinese_face() { return; } for (cjk, catalog) in [ - (theme::CjkPreference::SimplifiedChinese, include_str!("../src/i18n/zh-hans.tsv")), - (theme::CjkPreference::TraditionalChinese, include_str!("../src/i18n/zh-hant.tsv")), + (theme::CjkPreference::SimplifiedChinese, include_str!("../../src/i18n/zh-hans.tsv")), + (theme::CjkPreference::TraditionalChinese, include_str!("../../src/i18n/zh-hant.tsv")), ] { let defs = theme::installed_font_definitions(cjk); if !defs.font_data.contains_key(theme::SYSTEM_FALLBACK) { @@ -351,28 +351,28 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() { let fallback_font = skrifa::FontRef::from_index(&fallback.font, fallback.index).unwrap(); let fallback_charmap = fallback_font.charmap(); for (code, catalog) in [ - ("cs", include_str!("../src/i18n/cs.tsv")), - ("el", include_str!("../src/i18n/el.tsv")), - ("pt-br", include_str!("../src/i18n/pt-br.tsv")), - ("de", include_str!("../src/i18n/de.tsv")), - ("es", include_str!("../src/i18n/es.tsv")), - ("fr", include_str!("../src/i18n/fr.tsv")), - ("ru", include_str!("../src/i18n/ru.tsv")), - ("kk", include_str!("../src/i18n/kk.tsv")), - ("bg", include_str!("../src/i18n/bg.tsv")), - ("hu", include_str!("../src/i18n/hu.tsv")), - ("uk", include_str!("../src/i18n/uk.tsv")), - ("it", include_str!("../src/i18n/it.tsv")), - ("tr", include_str!("../src/i18n/tr.tsv")), - ("sl", include_str!("../src/i18n/sl.tsv")), - ("lt", include_str!("../src/i18n/lt.tsv")), - ("id", include_str!("../src/i18n/id.tsv")), - ("vi", include_str!("../src/i18n/vi.tsv")), - ("fi", include_str!("../src/i18n/fi.tsv")), - ("sq", include_str!("../src/i18n/sq.tsv")), - ("ca", include_str!("../src/i18n/ca.tsv")), - ("ro", include_str!("../src/i18n/ro.tsv")), - ("nb", include_str!("../src/i18n/nb.tsv")), + ("cs", include_str!("../../src/i18n/cs.tsv")), + ("el", include_str!("../../src/i18n/el.tsv")), + ("pt-br", include_str!("../../src/i18n/pt-br.tsv")), + ("de", include_str!("../../src/i18n/de.tsv")), + ("es", include_str!("../../src/i18n/es.tsv")), + ("fr", include_str!("../../src/i18n/fr.tsv")), + ("ru", include_str!("../../src/i18n/ru.tsv")), + ("kk", include_str!("../../src/i18n/kk.tsv")), + ("bg", include_str!("../../src/i18n/bg.tsv")), + ("hu", include_str!("../../src/i18n/hu.tsv")), + ("uk", include_str!("../../src/i18n/uk.tsv")), + ("it", include_str!("../../src/i18n/it.tsv")), + ("tr", include_str!("../../src/i18n/tr.tsv")), + ("sl", include_str!("../../src/i18n/sl.tsv")), + ("lt", include_str!("../../src/i18n/lt.tsv")), + ("id", include_str!("../../src/i18n/id.tsv")), + ("vi", include_str!("../../src/i18n/vi.tsv")), + ("fi", include_str!("../../src/i18n/fi.tsv")), + ("sq", include_str!("../../src/i18n/sq.tsv")), + ("ca", include_str!("../../src/i18n/ca.tsv")), + ("ro", include_str!("../../src/i18n/ro.tsv")), + ("nb", include_str!("../../src/i18n/nb.tsv")), ] { // Vietnamese stacks its diacritics: the precomposed letters and the base letters they // combine with, including `đ` and the tone marks `ạ ả ấ ầ ẩ ẫ ậ ắ ằ ẳ ẵ ặ ế ề ể ễ ệ`. diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/it/forms.rs similarity index 99% rename from crates/ui-egui/tests/forms.rs rename to crates/ui-egui/tests/it/forms.rs index efc27dc83..230addd65 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -8,7 +8,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); app.set_option("left", "closed").unwrap(); // The whole 300×400 pt page on screen. app.set_option("zoom", "150").unwrap(); @@ -609,7 +609,7 @@ fn close_all_saves_text_typed_in_a_tab_that_is_no_longer_active() { h.event(egui::Event::Text("Typed in tab one".into())); h.run_steps(2); // Another document becomes active while the first one's field still has its draft. - h.state_mut().open_bytes("other.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + h.state_mut().open_bytes("other.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); h.run_steps(2); h.state_mut().close_all(); assert!(h.state().close_request.is_some(), "the first document has an unsaved change"); @@ -876,7 +876,7 @@ fn a_save_shortcut_pressed_while_typing_saves_only_the_document_it_was_pressed_i one_frame(&mut h, vec![key(egui::Key::S, egui::Modifiers::COMMAND)]); assert!(!out.exists(), "⌘S while typing waits a frame for the field"); // Before the deferred ⌘S runs, another document becomes active. - h.state_mut().open_bytes("other.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + h.state_mut().open_bytes("other.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); h.run_steps(3); assert!(!out.exists(), "the other document is not saved by a shortcut pressed in the first"); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/ui-egui/tests/ui_group/identity.rs b/crates/ui-egui/tests/it/identity.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/identity.rs rename to crates/ui-egui/tests/it/identity.rs diff --git a/crates/ui-egui/tests/javascript.rs b/crates/ui-egui/tests/it/javascript.rs similarity index 100% rename from crates/ui-egui/tests/javascript.rs rename to crates/ui-egui/tests/it/javascript.rs diff --git a/crates/ui-egui/tests/ui_group/layer_actions.rs b/crates/ui-egui/tests/it/layer_actions.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/layer_actions.rs rename to crates/ui-egui/tests/it/layer_actions.rs diff --git a/crates/ui-egui/tests/ui_group/links.rs b/crates/ui-egui/tests/it/links.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/links.rs rename to crates/ui-egui/tests/it/links.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs new file mode 100644 index 000000000..7c04df20e --- /dev/null +++ b/crates/ui-egui/tests/it/main.rs @@ -0,0 +1,74 @@ +//! The crate's integration tests, linked into one test binary. `perf.rs` keeps its own process: +//! its frame-time budgets would charge each measurement for the other tests running beside it. + +mod support; + +/// Tests that render pixels (`h.render()`) take this first, so only one wgpu device exists at a +/// time. Lavapipe crashes when another test creates its Vulkan instance meanwhile (segfault in +/// `vkCreateSampler` during a concurrent `vkCreateInstance`). On machines without a GPU, wgpu falls +/// back to Microsoft's WARP, whose ARM64 pixel-shader JIT crashes (access violation in +/// `d3d10warp!PixelJitProgram::ClassifyVars`) when two devices compile at once, as on GitHub's +/// Windows 11 ARM64 runner. Hold it for the whole test (declared before the harness, so it's +/// released after the device is dropped). +static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn gpu() -> std::sync::MutexGuard<'static, ()> { + // A test that panicked while holding it leaves nothing to clean up. + GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +mod accessibility; +mod actions; +mod animate; +mod bookmark_position; +mod bookmarks; +mod bulk_fields; +mod buttons; +mod camera; +mod column_select; +mod combine_grid; +mod commands; +mod comments; +mod compare; +mod content; +mod control; +mod create; +mod date_format; +mod dialog_chrome; +mod document_links; +mod editing; +mod export; +mod fill_sign; +mod folders; +mod fonts; +mod forms; +mod identity; +mod javascript; +mod layer_actions; +mod links; +mod marks; +mod object_selection; +mod ocr; +mod pageboxes; +mod panel_snapshots; +mod pdf_links; +mod pickers; +mod preferences_layout; +mod prepare; +mod print; +mod protect; +mod reading_colors; +mod recovery; +mod redact; +mod reload; +mod signing; +mod stamps; +mod text_select; +mod theme; +mod theme_button; +mod trust_signer; +mod ui; +mod updates; +mod view; +mod viewer; +mod xfa; diff --git a/crates/ui-egui/tests/ui_group/marks.rs b/crates/ui-egui/tests/it/marks.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/marks.rs rename to crates/ui-egui/tests/it/marks.rs diff --git a/crates/ui-egui/tests/object_selection.rs b/crates/ui-egui/tests/it/object_selection.rs similarity index 99% rename from crates/ui-egui/tests/object_selection.rs rename to crates/ui-egui/tests/it/object_selection.rs index ceb700a1c..6e428ad74 100644 --- a/crates/ui-egui/tests/object_selection.rs +++ b/crates/ui-egui/tests/it/object_selection.rs @@ -141,6 +141,7 @@ fn marquee_and_mixed_group_drag_move_once_and_undo_together() { let app = h.state(); assert_eq!(app.session.get(app.views[0].id).unwrap().can_undo(), Some("Move objects")); if let Ok(path) = std::env::var("PDFCRAFT_GROUP_SHOT") { + let _gpu = crate::gpu(); h.render().unwrap().save(path).unwrap(); } h.key_press_modifiers(Modifiers::COMMAND, Key::Z); diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/it/ocr.rs similarity index 100% rename from crates/ui-egui/tests/ocr.rs rename to crates/ui-egui/tests/it/ocr.rs diff --git a/crates/ui-egui/tests/ui_group/pageboxes.rs b/crates/ui-egui/tests/it/pageboxes.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/pageboxes.rs rename to crates/ui-egui/tests/it/pageboxes.rs diff --git a/crates/ui-egui/tests/panel_snapshots.rs b/crates/ui-egui/tests/it/panel_snapshots.rs similarity index 98% rename from crates/ui-egui/tests/panel_snapshots.rs rename to crates/ui-egui/tests/it/panel_snapshots.rs index 652a670a3..c9cc1d590 100644 --- a/crates/ui-egui/tests/panel_snapshots.rs +++ b/crates/ui-egui/tests/it/panel_snapshots.rs @@ -5,7 +5,7 @@ //! and a reviewer can read a change in the diff. Positions are left out, so moving a widget is not //! a change but adding, removing or renaming one is. //! -//! `UPDATE_SNAPSHOTS=1 cargo test -p pdfcraft-ui-egui --test panel_snapshots` rewrites the files. +//! `UPDATE_SNAPSHOTS=1 cargo test -p pdfcraft-ui-egui --test it panel_snapshots` rewrites the files. use egui::accesskit::{Role, Toggled}; use egui_kittest::kittest::{NodeT, Queryable}; @@ -13,7 +13,7 @@ use egui_kittest::{Harness, Node}; use pdfcraft_engine::Session; use pdfcraft_ui_egui::{PdfCraftApp, RightPanel}; -mod support; +use crate::support; /// Two pages with something for every panel: bookmarks, a layer, an attachment, a text field, an /// unsigned signature field, a comment and a structure tree. diff --git a/crates/ui-egui/tests/ui_group/pdf_links.rs b/crates/ui-egui/tests/it/pdf_links.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/pdf_links.rs rename to crates/ui-egui/tests/it/pdf_links.rs diff --git a/crates/ui-egui/tests/pickers.rs b/crates/ui-egui/tests/it/pickers.rs similarity index 99% rename from crates/ui-egui/tests/pickers.rs rename to crates/ui-egui/tests/it/pickers.rs index 5c4436f19..e4ce56d7c 100644 --- a/crates/ui-egui/tests/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -4,7 +4,7 @@ //! used on a later frame. `pick_override` stands in for the native panel and, like it, answers //! on a later frame. -mod support; +use crate::support; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/tests/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs similarity index 100% rename from crates/ui-egui/tests/preferences_layout.rs rename to crates/ui-egui/tests/it/preferences_layout.rs diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/it/prepare.rs similarity index 99% rename from crates/ui-egui/tests/prepare.rs rename to crates/ui-egui/tests/it/prepare.rs index a702ff27e..106d2d539 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -13,7 +13,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { cc.egui_ctx.options_mut(|options| options.warn_on_id_clash = true); let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); app.set_option("zoom", "150").unwrap(); app }); diff --git a/crates/ui-egui/tests/print.rs b/crates/ui-egui/tests/it/print.rs similarity index 99% rename from crates/ui-egui/tests/print.rs rename to crates/ui-egui/tests/it/print.rs index 47bef8c95..ae325eaaa 100644 --- a/crates/ui-egui/tests/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -16,7 +16,7 @@ fn harness_with_settings(settings: Option) -> Harness<'static, PdfCraftA if let Some(json) = &settings { app.restore(json); } - app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); app }); h.run_steps(4); @@ -705,7 +705,7 @@ fn a_dead_options_read_wakes_an_idle_dialog() { /// top bar's Print button is disabled, instead of the dialog refusing on confirm. #[test] fn print_restricted_documents_say_so_before_the_dialog() { - let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(include_bytes!("data/form.pdf").to_vec())).unwrap(); + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(include_bytes!("../data/form.pdf").to_vec())).unwrap(); doc.set_encryption(&pdfcraft_cos::NewEncryption { algorithm: pdfcraft_cos::Algorithm::Aes256, user_password: "", diff --git a/crates/ui-egui/tests/ui_group/protect.rs b/crates/ui-egui/tests/it/protect.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/protect.rs rename to crates/ui-egui/tests/it/protect.rs diff --git a/crates/ui-egui/tests/reading_colors.rs b/crates/ui-egui/tests/it/reading_colors.rs similarity index 100% rename from crates/ui-egui/tests/reading_colors.rs rename to crates/ui-egui/tests/it/reading_colors.rs diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/it/recovery.rs similarity index 99% rename from crates/ui-egui/tests/recovery.rs rename to crates/ui-egui/tests/it/recovery.rs index a259ca3ad..5e2ffaad7 100644 --- a/crates/ui-egui/tests/recovery.rs +++ b/crates/ui-egui/tests/it/recovery.rs @@ -1,7 +1,7 @@ //! Autosave and crash recovery: a session is "crashed" by dropping the app without a clean //! quit, and a new session must offer, recover or discard what was autosaved. -mod support; +use crate::support; use egui::accesskit::Role; use egui::{Key, Modifiers}; diff --git a/crates/ui-egui/tests/ui_group/redact.rs b/crates/ui-egui/tests/it/redact.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/redact.rs rename to crates/ui-egui/tests/it/redact.rs diff --git a/crates/ui-egui/tests/reload.rs b/crates/ui-egui/tests/it/reload.rs similarity index 100% rename from crates/ui-egui/tests/reload.rs rename to crates/ui-egui/tests/it/reload.rs diff --git a/crates/ui-egui/tests/signing.rs b/crates/ui-egui/tests/it/signing.rs similarity index 100% rename from crates/ui-egui/tests/signing.rs rename to crates/ui-egui/tests/it/signing.rs diff --git a/crates/ui-egui/tests/stamps.rs b/crates/ui-egui/tests/it/stamps.rs similarity index 94% rename from crates/ui-egui/tests/stamps.rs rename to crates/ui-egui/tests/it/stamps.rs index 46798e8c0..767cc7eb4 100644 --- a/crates/ui-egui/tests/stamps.rs +++ b/crates/ui-egui/tests/it/stamps.rs @@ -9,7 +9,7 @@ fn choosing_and_placing_stamps() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); app.set_option("zoom", "150").unwrap(); app }); @@ -40,6 +40,7 @@ fn choosing_and_placing_stamps() { std::thread::sleep(std::time::Duration::from_millis(10)); } if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + let _gpu = crate::gpu(); h.render().unwrap().save(format!("{dir}/stamps.png")).unwrap(); } } @@ -50,7 +51,7 @@ fn natural_image_stamps_keep_displayed_bounds_on_rotated_pages() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); let id = app.views[0].id; app.session.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees }).unwrap(); app.set_option("zoom", "100").unwrap(); @@ -92,7 +93,7 @@ fn creating_placing_and_keeping_custom_stamps() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); app.set_option("zoom", "150").unwrap(); app }); diff --git a/crates/ui-egui/tests/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs similarity index 100% rename from crates/ui-egui/tests/support/mod.rs rename to crates/ui-egui/tests/it/support/mod.rs diff --git a/crates/ui-egui/tests/ui_group/text_select.rs b/crates/ui-egui/tests/it/text_select.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/text_select.rs rename to crates/ui-egui/tests/it/text_select.rs diff --git a/crates/ui-egui/tests/ui_group/theme.rs b/crates/ui-egui/tests/it/theme.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/theme.rs rename to crates/ui-egui/tests/it/theme.rs diff --git a/crates/ui-egui/tests/theme_button.rs b/crates/ui-egui/tests/it/theme_button.rs similarity index 100% rename from crates/ui-egui/tests/theme_button.rs rename to crates/ui-egui/tests/it/theme_button.rs diff --git a/crates/ui-egui/tests/trust_signer.rs b/crates/ui-egui/tests/it/trust_signer.rs similarity index 100% rename from crates/ui-egui/tests/trust_signer.rs rename to crates/ui-egui/tests/it/trust_signer.rs diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/it/ui.rs similarity index 99% rename from crates/ui-egui/tests/ui.rs rename to crates/ui-egui/tests/it/ui.rs index e392a6614..6472b7c66 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -1,6 +1,6 @@ //! Headless UI tests (egui_kittest + AccessKit). They drive the real app shell without a window. -mod support; +use crate::support; use egui_kittest::Harness; use egui_kittest::kittest::{By, NodeT, Queryable}; @@ -450,6 +450,7 @@ fn overflowing_tabs_can_be_reached_by_scrolling() { assert_eq!(h.state().views.len(), 19); assert!(h.state().active.is_some_and(|i| i < 19)); if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { + let _gpu = crate::gpu(); settle(&mut h); h.render().unwrap().save(format!("{dir}/overflow-tabs.png")).unwrap(); } @@ -563,6 +564,7 @@ fn narrow_tab_strips_accept_vertical_wheel_scrolling() { assert_eq!(h.state().active, Some(11)); assert!(h.get_by_label_contains("Display theme:").rect().right() <= width); if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { + let _gpu = crate::gpu(); settle(&mut h); h.render().unwrap().save(format!("{dir}/tabs-{width}.png")).unwrap(); } @@ -1318,6 +1320,7 @@ fn document_tabs_expose_selection_and_activate_from_keyboard() { assert!(!doc.dirty); } if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { + let _gpu = crate::gpu(); settle(&mut h); h.render().unwrap().save(format!("{dir}/focused-tab-{key:?}.png")).unwrap(); } @@ -1406,6 +1409,7 @@ fn dragging_a_tab_moves_it_and_keeps_the_same_document_active() { assert!(!doc.dirty); } if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { + let _gpu = crate::gpu(); let to = tab(&h, "first.pdf").left_center() - egui::vec2(10.0, 0.0); drag_named_tab(&mut h, "last.pdf", to, false); h.render().unwrap().save(format!("{dir}/dragging-tab.png")).unwrap(); @@ -1417,6 +1421,7 @@ fn dragging_a_tab_moves_it_and_keeps_the_same_document_active() { #[test] fn the_drop_line_shows_in_front_of_the_first_tab() { use egui::accesskit::Role; + let _gpu = crate::gpu(); let mut h = three_tab_harness(); let first = h.get_by_role_and_label(Role::Tab, "first.pdf").rect(); // Pixels in the strip just left of the first tab that are strongly blue (the accent line). diff --git a/crates/ui-egui/tests/ui_group/updates.rs b/crates/ui-egui/tests/it/updates.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/updates.rs rename to crates/ui-egui/tests/it/updates.rs diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/it/view.rs similarity index 99% rename from crates/ui-egui/tests/view.rs rename to crates/ui-egui/tests/it/view.rs index ba795d529..dc1d5585d 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -16,17 +16,7 @@ const PAGES: &[u8] = b"%PDF-1.7 trailer << /Root 1 0 R >> %%EOF"; -/// Tests that render pixels (`h.render()`) take this first, so only one wgpu device compiles -/// shaders at a time. On machines without a GPU, wgpu falls back to Microsoft's WARP, whose ARM64 -/// pixel-shader JIT crashes (access violation in `d3d10warp!PixelJitProgram::ClassifyVars`) when -/// two devices compile at once, as on GitHub's Windows 11 ARM64 runner. Hold it for the whole -/// test (declared before the harness, so it's released after the device is dropped). -static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -fn gpu() -> std::sync::MutexGuard<'static, ()> { - // A test that panicked while holding it leaves nothing to clean up. - GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) -} +use crate::gpu; fn harness(options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> { harness_stepping(1.0 / 4.0, options) diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/it/viewer.rs similarity index 99% rename from crates/ui-egui/tests/viewer.rs rename to crates/ui-egui/tests/it/viewer.rs index 7bb1bbb3e..a3c86fc52 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -1429,6 +1429,7 @@ fn pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection() assert_eq!(h.state().views[0].zoom, zoom, "only the thumbnails are resized"); assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); if let Ok(dir) = std::env::var("PDFCRAFT_THUMBNAIL_SHOTS") { + let _gpu = crate::gpu(); h.render().unwrap().save(format!("{dir}/small.png")).unwrap(); } h.get_by_label("Larger thumbnails").click(); @@ -1439,6 +1440,7 @@ fn pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection() assert!(thumbnail_rect(&h, 1).width() > usual); assert_eq!(h.state().views[0].target_pages(), vec![0, 1]); if let Ok(dir) = std::env::var("PDFCRAFT_THUMBNAIL_SHOTS") { + let _gpu = crate::gpu(); h.render().unwrap().save(format!("{dir}/large.png")).unwrap(); h.state_mut().set_option("theme", "dark").unwrap(); h.run_steps(4); diff --git a/crates/ui-egui/tests/ui_group/xfa.rs b/crates/ui-egui/tests/it/xfa.rs similarity index 100% rename from crates/ui-egui/tests/ui_group/xfa.rs rename to crates/ui-egui/tests/it/xfa.rs diff --git a/crates/ui-egui/tests/ui_group/main.rs b/crates/ui-egui/tests/ui_group/main.rs deleted file mode 100644 index 5f0215dd4..000000000 --- a/crates/ui-egui/tests/ui_group/main.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! One test binary for the UI tests that are safe to share a process (#454): each links the whole -//! UI and render stack, so grouping them saves a link unit apiece. Tests that hold a GPU device, -//! write screenshots, use process-ID temporary paths or probe installed fonts stay standalone -//! in `tests/`. A file in this folder is only built if it is listed here. - -#[path = "../support/mod.rs"] -mod support; - -mod animate; -mod bulk_fields; -mod column_select; -mod identity; -mod layer_actions; -mod links; -mod marks; -mod pageboxes; -mod pdf_links; -mod protect; -mod redact; -mod text_select; -mod theme; -mod updates; -mod xfa; diff --git a/docs/editing-multiple-objects.md b/docs/editing-multiple-objects.md index 2eb7d84a5..7881be6a7 100644 --- a/docs/editing-multiple-objects.md +++ b/docs/editing-multiple-objects.md @@ -83,5 +83,5 @@ undo, redo, or save; there is no claim that an index persists across generations Regression coverage: `crates/edit/src/tests.rs` (glyph bytes, neighbours, split streams, shared Forms, rotated/cropped/UserUnit pages and refusal), `crates/engine/src/tests.rs` (permissions, history, -rendering and save/reopen), `crates/automation/tests/automation.rs` (generation guards and hostile -references), and `crates/ui-egui/tests/object_selection.rs` (real-shell selection and gestures). +rendering and save/reopen), `crates/automation/tests/it/automation.rs` (generation guards and hostile +references), and `crates/ui-egui/tests/it/object_selection.rs` (real-shell selection and gestures). diff --git a/docs/gaps.md b/docs/gaps.md index 278ab2330..54935c77b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -254,7 +254,7 @@ update the numbers. reference image and no oracle. **We render 1 distinct image across all 96; Poppler renders 8**, drawing a blank page for the seven halftone-skip and symbol fixtures and mis-decoding TPGRON refinement (its output changes when TPGRON is switched on, which a correct decoder's cannot). - Pinned as `crates/render/tests/jbig2_invariants.rs`. **Poppler is not a usable oracle for JBIG2**, + Pinned as `crates/render/tests/it/jbig2_invariants.rs`. **Poppler is not a usable oracle for JBIG2**, and roughly a fifth of its fixtures should be read as its bugs rather than ours. - **Annotations are shown, and that was measured rather than argued.** Poppler synthesises an appearance for an annotation that has none, where we draw only what the document carries (link diff --git a/docs/localization.md b/docs/localization.md index 586a85ba5..ccacccd09 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -26,7 +26,7 @@ The Vietnamese catalog has 2,674 entries and one plural form (`plural_none`), co The Finnish catalog has 2,674 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. -The Catalan catalog has 2,592 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation. +The Catalan catalog has 2,592 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/it/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation. The Romanian catalog (`ro`, Română) has 2,673 entries and three plural forms (`plural_ro`, CLDR `ro`): `one` for 1, `few` for 0 and counts whose last two digits are 1–19 ("2 pagini", "101 pagini"), `other` for the rest, written with "de" ("20 de pagini"). Commands use the noun form common in Romanian software ("Salvare", "Deschidere…"); messages address the user formally. Redaction is "Anonimizare" ("redactare" means editing in Romanian), a watermark "Filigran". Calendar month names are lowercase inside dates (`{day} {month} {y}`). The bundled Inter faces cover ă, â, î, ș, ț (comma below). Coverage tests require every bundled key. Not yet reviewed by a second native speaker; status `partial`: no translated help, and engine/OS details stay English inside a translated frame. @@ -34,7 +34,7 @@ The Norwegian Bokmål catalog covers every key the other bundled catalogs have, Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet in Edit text. Add text (#128) draws Hebrew with the same face through the shaped path Arabic and Thai use: right to left with numbers and Latin in place, points kept on their letter, and typing order for copy and search. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters, and refuse Hebrew in Add text. -Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Until craft-fonts has an allowed `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux; never Noto CJK or Source Han), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Slovenian, Turkish, Lithuanian, Norwegian, Finnish, Albanian, Catalan and Romanian need only the bundled Latin faces (tested in `tests/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. +Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Until craft-fonts has an allowed `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux; never Noto CJK or Source Han), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Slovenian, Turkish, Lithuanian, Norwegian, Finnish, Albanian, Catalan and Romanian need only the bundled Latin faces (tested in `tests/it/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. The Brazilian Portuguese catalog has 2,674 entries and covers the current union of catalog keys. It began with the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), with labels added since and the existing wording reviewed. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`). diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c06f26e24..362c2aa77 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -22,7 +22,7 @@ milestone = "M2" status = "shipped" commands = ["file.word_count"] tools = ["text_word_count"] -evidence = ["crates/engine/src/word_count.rs::word_count_filters_are_independent_and_do_not_change_normal_extraction", "crates/automation/tests/automation.rs::word_count_reads_selected_pages_and_unsaved_edits_without_mutating", "crates/ui-egui/src/word_count_ui.rs::word_count_dialog_counts_changes_pages_and_closes"] +evidence = ["crates/engine/src/word_count.rs::word_count_filters_are_independent_and_do_not_change_normal_extraction", "crates/automation/tests/it/automation.rs::word_count_reads_selected_pages_and_unsaved_edits_without_mutating", "crates/ui-egui/src/word_count_ui.rs::word_count_dialog_counts_changes_pages_and_closes"] notes = "Unicode words and characters for the current snapshot; all or current page in GUI, selected pages headlessly. Desktop background work with cancellation. Optional semantic table/code/Figure and invisible-text exclusions; no OCR or visual layout classification. Unsupported structure remains counted and is reported. Mixed ActualText exclusions fail explicitly. See docs/word-count.md." [[feature]] @@ -34,7 +34,7 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits", "crates/ui-egui/tests/editing.rs::opening_the_same_path_again_focuses_its_tab", "crates/ui-egui/tests/editing.rs::opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab"] +evidence = ["crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits", "crates/ui-egui/tests/it/editing.rs::opening_the_same_path_again_focuses_its_tab", "crates/ui-egui/tests/it/editing.rs::opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab"] notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type); an already-open file focuses its tab instead of opening a second copy (#1620). Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)" [[feature]] @@ -46,7 +46,7 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0"] +evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/it/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0"] notes = "Save and doc_save write a reconstructed file in full even when nothing was edited, so the repair reaches the disk and the saved file opens without one (#1937); a signed one keeps its bytes, so its signatures stay valid. A repair does not mark the document as having unsaved changes, so closing it does not offer to save the repaired file. A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged." [[feature]] @@ -58,7 +58,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::garbage_input_is_rejected_without_panicking", "crates/cos/src/parser.rs::garbage_never_panics", "crates/render/src/raster.rs::missing_page_and_garbage_input_fail_gracefully"] +evidence = ["crates/ui-egui/tests/it/ui.rs::garbage_input_is_rejected_without_panicking", "crates/cos/src/parser.rs::garbage_never_panics", "crates/render/src/raster.rs::missing_page_and_garbage_input_fail_gracefully"] notes = "fuzzed nightly (M1.10)" [[feature]] @@ -70,7 +70,7 @@ milestone = "M1" status = "shipped" commands = [] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/viewer.rs::damaged_files_say_they_were_repaired"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::damaged_files_say_they_were_repaired"] notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them" [[feature]] @@ -82,7 +82,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/cos/tests/writer.rs::incremental_saves_stack_on_an_xref_stream_file", "crates/cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers"] +evidence = ["crates/cos/tests/it/writer.rs::incremental_saves_stack_on_an_xref_stream_file", "crates/cos/tests/it/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers"] notes = "" [[feature]] @@ -154,7 +154,7 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/crypt/src/tests.rs::rc4_reference_vectors", "crates/cos/tests/encryption.rs::every_algorithm_round_trips_and_hides_the_content"] +evidence = ["crates/crypt/src/tests.rs::rc4_reference_vectors", "crates/cos/tests/it/encryption.rs::every_algorithm_round_trips_and_hides_the_content"] notes = "" [[feature]] @@ -166,7 +166,7 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/crypt/src/tests.rs::aes_cbc_nist_sp800_38a_vectors", "crates/cos/tests/encryption.rs::every_algorithm_round_trips_and_hides_the_content"] +evidence = ["crates/crypt/src/tests.rs::aes_cbc_nist_sp800_38a_vectors", "crates/cos/tests/it/encryption.rs::every_algorithm_round_trips_and_hides_the_content"] notes = "" [[feature]] @@ -178,7 +178,7 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/ui-egui/tests/editing.rs::password_prompt_opens_and_security_tab_reports_the_details", "crates/crypt/src/tests.rs::empty_user_password_opens_without_asking", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_survives_wrong_password_retry_and_runs_after_unlock", "crates/ui-egui/src/open_worker_tests.rs::startup_intake_waits_for_local_authentication_and_keeps_its_own_background_password_prompt", "crates/ui-egui/tests/pickers.rs::the_open_password_can_be_typed_through_an_input_method_however_the_file_is_opened"] +evidence = ["crates/ui-egui/tests/it/editing.rs::password_prompt_opens_and_security_tab_reports_the_details", "crates/crypt/src/tests.rs::empty_user_password_opens_without_asking", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_survives_wrong_password_retry_and_runs_after_unlock", "crates/ui-egui/src/open_worker_tests.rs::startup_intake_waits_for_local_authentication_and_keeps_its_own_background_password_prompt", "crates/ui-egui/tests/it/pickers.rs::the_open_password_can_be_typed_through_an_input_method_however_the_file_is_opened"] notes = "the field takes typing through the platform input method, which typed keys go through on Linux, however the file was opened (#2818); checked with synthetic input-method events, not on a Linux desktop" [[feature]] @@ -202,7 +202,7 @@ milestone = "M1" status = "shipped" commands = ["protect.unlock"] tools = ["doc_open"] -evidence = ["crates/engine/src/tests.rs::restricted_documents_refuse_changes_unless_opened_by_the_owner", "crates/engine/src/tests.rs::owner_password_of_older_revisions_opens_the_viewer_too", "crates/engine/src/tests.rs::render_password_for_recovers_the_user_password_of_older_revisions", "apps/pdfcraft-cli/tests/owner_password.rs::info_accepts_the_owner_password", "apps/pdfcraft-cli/tests/owner_password.rs::text_accepts_the_owner_password", "crates/crypt/src/tests.rs::owner_authentication_recovers_the_user_password", "crates/engine/src/tests.rs::the_permissions_password_unlocks_an_open_document", "crates/ui-egui/tests/editing.rs::the_security_notice_unlocks_with_the_permissions_password", "crates/ui-egui/tests/editing.rs::remove_security_asks_for_the_permissions_password_first", "crates/ui-egui/tests/pickers.rs::the_permissions_password_can_be_typed_through_an_input_method"] +evidence = ["crates/engine/src/tests.rs::restricted_documents_refuse_changes_unless_opened_by_the_owner", "crates/engine/src/tests.rs::owner_password_of_older_revisions_opens_the_viewer_too", "crates/engine/src/tests.rs::render_password_for_recovers_the_user_password_of_older_revisions", "apps/pdfcraft-cli/tests/it/owner_password.rs::info_accepts_the_owner_password", "apps/pdfcraft-cli/tests/it/owner_password.rs::text_accepts_the_owner_password", "crates/crypt/src/tests.rs::owner_authentication_recovers_the_user_password", "crates/engine/src/tests.rs::the_permissions_password_unlocks_an_open_document", "crates/ui-egui/tests/it/editing.rs::the_security_notice_unlocks_with_the_permissions_password", "crates/ui-egui/tests/it/editing.rs::remove_security_asks_for_the_permissions_password_first", "crates/ui-egui/tests/it/pickers.rs::the_permissions_password_can_be_typed_through_an_input_method"] notes = "at open (the password prompt takes either password), or after opening a restricted file that needed no password (#995, #1077): Unlock… on the security notice and in Properties ▸ Security, and changing security asks for it first. The CLI's info, text, render and audit take either password too (#2670). Not yet: an automation tool to unlock an open document (reopen with doc_open and the password instead)" [[feature]] @@ -214,7 +214,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/crypt/src/tests.rs::perms_limits_what_an_edited_p_grants", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly", "crates/engine/src/tests.rs::print_restricted_documents_disable_print_unless_opened_by_the_owner", "crates/ui-egui/tests/print.rs::print_restricted_documents_say_so_before_the_dialog"] +evidence = ["crates/ui-egui/tests/it/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/crypt/src/tests.rs::perms_limits_what_an_edited_p_grants", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly", "crates/engine/src/tests.rs::print_restricted_documents_disable_print_unless_opened_by_the_owner", "crates/ui-egui/tests/it/print.rs::print_restricted_documents_say_so_before_the_dialog"] notes = "" [[feature]] @@ -250,7 +250,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/cos/tests/encryption.rs::unencrypted_metadata_is_left_readable"] +evidence = ["crates/cos/tests/it/encryption.rs::unencrypted_metadata_is_left_readable"] notes = "" [[feature]] @@ -286,7 +286,7 @@ milestone = "M1" status = "shipped" commands = ["file.save"] tools = ["doc_save"] -evidence = ["crates/platform/src/staging.rs::atomic_saves_keep_finder_tags_and_binary_extended_attributes", "crates/platform/src/staging.rs::a_growing_resource_fork_refuses_publication_and_keeps_original", "crates/automation/tests/automation.rs::saving_a_pdf_keeps_the_destinations_extended_attributes", "crates/automation/tests/automation.rs::a_metadata_preservation_error_keeps_the_pdf_and_unsaved_edit", "crates/ui-egui/tests/editing.rs::save_writes_an_incremental_update_and_clears_dirty", "crates/engine/src/tests.rs::save_is_incremental_stamps_mod_date_and_rebases", "crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions", "crates/automation/src/lib.rs::staging_never_writes_through_a_file_planted_at_its_name", "crates/ui-egui/src/editing.rs::saving_never_writes_through_a_file_planted_at_the_staging_name"] +evidence = ["crates/platform/src/staging.rs::atomic_saves_keep_finder_tags_and_binary_extended_attributes", "crates/platform/src/staging.rs::a_growing_resource_fork_refuses_publication_and_keeps_original", "crates/automation/tests/it/automation.rs::saving_a_pdf_keeps_the_destinations_extended_attributes", "crates/automation/tests/it/automation.rs::a_metadata_preservation_error_keeps_the_pdf_and_unsaved_edit", "crates/ui-egui/tests/it/editing.rs::save_writes_an_incremental_update_and_clears_dirty", "crates/engine/src/tests.rs::save_is_incremental_stamps_mod_date_and_rebases", "crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions", "crates/automation/src/lib.rs::staging_never_writes_through_a_file_planted_at_its_name", "crates/ui-egui/src/editing.rs::saving_never_writes_through_a_file_planted_at_the_staging_name"] notes = "The atomic write stages to a new, randomly named file created with create_new, so a file or link planted at the staging path is never written through (desktop app, automation and CLI). On macOS, accessible destination xattrs (including Finder tags) are copied before flush/rename; errors, metadata limits or a changed post-read size refuse the save without replacing the file. System-managed provenance is excluded. Permissions, ACLs, hard-link identity and creation dates are not preserved by this change (#2658)." [[feature]] @@ -310,7 +310,7 @@ milestone = "M1" status = "shipped" commands = ["file.save_as"] tools = ["doc_save"] -evidence = ["crates/cos/tests/writer.rs::full_save_packs_objects_into_object_streams", "crates/cos/tests/writer.rs::qpdf_accepts_object_stream_output", "crates/cos/tests/writer.rs::objects_referenced_by_the_encrypt_dictionary_stay_outside_object_streams"] +evidence = ["crates/cos/tests/it/writer.rs::full_save_packs_objects_into_object_streams", "crates/cos/tests/it/writer.rs::qpdf_accepts_object_stream_output", "crates/cos/tests/it/writer.rs::objects_referenced_by_the_encrypt_dictionary_stay_outside_object_streams"] notes = "" [[feature]] @@ -322,7 +322,7 @@ milestone = "M1" status = "shipped" commands = ["file.save"] tools = ["doc_save"] -evidence = ["crates/cos/tests/encryption.rs::incremental_edits_of_encrypted_documents_stay_encrypted", "crates/engine/src/tests.rs::encrypted_documents_open_edit_and_save_encrypted"] +evidence = ["crates/cos/tests/it/encryption.rs::incremental_edits_of_encrypted_documents_stay_encrypted", "crates/engine/src/tests.rs::encrypted_documents_open_edit_and_save_encrypted"] notes = "" [[feature]] @@ -346,7 +346,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/cos/tests/encryption.rs::qpdf_agrees", "crates/cos/tests/writer.rs::qpdf_accepts_object_stream_output", "crates/platform/src/staging.rs::an_atomic_write_replaces_the_target_only_when_complete", "apps/pdfcraft-cli/tests/edit.rs::editing_in_place_leaves_a_valid_file_and_no_staging_file", "crates/automation/tests/automation.rs::a_root_less_write_output_is_atomic_too"] +evidence = ["crates/cos/tests/it/encryption.rs::qpdf_agrees", "crates/cos/tests/it/writer.rs::qpdf_accepts_object_stream_output", "crates/platform/src/staging.rs::an_atomic_write_replaces_the_target_only_when_complete", "apps/pdfcraft-cli/tests/it/edit.rs::editing_in_place_leaves_a_valid_file_and_no_staging_file", "crates/automation/tests/it/automation.rs::a_root_less_write_output_is_atomic_too"] notes = "qpdf is installed on the Linux CI runner and PDFCRAFT_ORACLES_REQUIRED makes its absence a failure, so these run on every CI run rather than skipping; on a machine without qpdf they skip with a notice (crates/oracle). Every PDF leaves the engine checked (it reopens with its pages): saves, new files from Combine/Extract/Split/Create, signed, timestamped and LTV-embedded files, OCR batch output; and every writer goes through one staging-file writer (the desktop app, the tools with or without --root, the CLI), so a failed write never leaves a truncated file (#1698, #2166)" [[feature]] @@ -382,7 +382,7 @@ milestone = "M1" status = "shipped" commands = ["file.properties"] tools = ["doc_revisions"] -evidence = ["crates/cos/src/document.rs::revision_ends_split_incremental_updates", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +evidence = ["crates/cos/src/document.rs::revision_ends_split_incremental_updates", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] notes = "revision ends from the xref chain (linearized first-page sections fold into their revision); listed in Document Properties ▸ Advanced and by doc_revisions with the signatures covering each" [[feature]] @@ -394,7 +394,7 @@ milestone = "M9" status = "shipped" commands = ["file.properties"] tools = ["doc_open_revision"] -evidence = ["crates/ui-egui/tests/viewer.rs::an_earlier_revision_opens_from_document_properties", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::an_earlier_revision_opens_from_document_properties", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] notes = "opens the revision's bytes as a new unsaved document named ' (revision n)'" [[feature]] @@ -478,7 +478,7 @@ milestone = "M4" status = "shipped" commands = ["edit.undo", "edit.redo"] tools = ["edit_undo", "edit_redo"] -evidence = ["crates/engine/src/tests.rs::undo_and_redo_restore_exact_states", "crates/ui-egui/tests/editing.rs::organize_delete_then_undo_and_redo_with_keys"] +evidence = ["crates/engine/src/tests.rs::undo_and_redo_restore_exact_states", "crates/ui-egui/tests/it/editing.rs::organize_delete_then_undo_and_redo_with_keys"] notes = "" [[feature]] @@ -490,7 +490,7 @@ milestone = "M4" status = "shipped" commands = ["edit.undo", "edit.redo"] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::edit_menu_names_the_step_to_undo", "crates/engine/src/commands.rs::enablement_follows_the_document_state"] +evidence = ["crates/ui-egui/tests/it/editing.rs::edit_menu_names_the_step_to_undo", "crates/engine/src/commands.rs::enablement_follows_the_document_state"] notes = "" [[feature]] @@ -538,7 +538,7 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/engine/src/tests.rs::autosave_snapshots_only_changed_documents", "crates/ui-egui/tests/recovery.rs::saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents"] +evidence = ["crates/engine/src/tests.rs::autosave_snapshots_only_changed_documents", "crates/ui-egui/tests/it/recovery.rs::saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents"] notes = "" [[feature]] @@ -550,7 +550,7 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/recovery.rs::a_crashed_session_is_offered_and_recovered_unsaved", "crates/ui-egui/tests/recovery.rs::discarding_removes_the_autosaves", "crates/ui-egui/tests/recovery.rs::a_running_instances_autosaves_are_not_offered_to_a_second_instance", "crates/engine/src/tests.rs::recovered_documents_reopen_unsaved_at_their_original_path"] +evidence = ["crates/ui-egui/tests/it/recovery.rs::a_crashed_session_is_offered_and_recovered_unsaved", "crates/ui-egui/tests/it/recovery.rs::discarding_removes_the_autosaves", "crates/ui-egui/tests/it/recovery.rs::a_running_instances_autosaves_are_not_offered_to_a_second_instance", "crates/engine/src/tests.rs::recovered_documents_reopen_unsaved_at_their_original_path"] notes = "A running instance holds an exclusively locked instance file in the recovery folder and names it in its snapshots, so a second instance neither offers nor discards what the first is autosaving (#2166); the OS releases the lock when the process dies. Where the folder can't be written or the platform has no file locks, snapshots are offered as before." [[feature]] @@ -562,7 +562,7 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/recovery.rs::encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password"] +evidence = ["crates/ui-egui/tests/it/recovery.rs::encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password"] notes = "" [[feature]] @@ -586,7 +586,7 @@ milestone = "M4" status = "shipped" commands = ["file.close"] tools = ["doc_close"] -evidence = ["crates/ui-egui/tests/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename", "crates/ui-egui/tests/editing.rs::the_save_prompt_moves_between_buttons_with_the_keyboard", "crates/ui-egui/tests/editing.rs::the_save_prompt_escape_still_cancels_whatever_is_chosen", "crates/ui-egui/tests/editing.rs::the_save_prompt_starts_on_save_every_time_it_opens"] +evidence = ["crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/it/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_moves_between_buttons_with_the_keyboard", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_escape_still_cancels_whatever_is_chosen", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_starts_on_save_every_time_it_opens"] notes = "Save is chosen when the prompt opens, so Enter saves. Left/Right (and Up/Down) and Tab/Shift+Tab move the choice between Don't save, Cancel and Save, shown with a focus ring; Enter or Space press the chosen button; Escape cancels. Alt+D, Alt+N and Ctrl/Cmd+D still answer Don't save. Not yet: the same keys on the other confirmations (Revert, reload, overwrite)." [[feature]] @@ -598,7 +598,7 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::organize_delete_then_undo_and_redo_with_keys", "crates/engine/src/tests.rs::edits_update_view_data_and_mark_dirty"] +evidence = ["crates/ui-egui/tests/it/editing.rs::organize_delete_then_undo_and_redo_with_keys", "crates/engine/src/tests.rs::edits_update_view_data_and_mark_dirty"] notes = "" [[feature]] @@ -610,7 +610,7 @@ milestone = "M3" status = "shipped" commands = ["file.close"] tools = ["doc_close"] -evidence = ["crates/ui-egui/tests/editing.rs::clean_tabs_close_without_asking"] +evidence = ["crates/ui-egui/tests/it/editing.rs::clean_tabs_close_without_asking"] notes = "" [[feature]] @@ -622,7 +622,7 @@ milestone = "M3" status = "shipped" commands = ["file.close_all"] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::close_all_asks_only_for_changed_documents"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::close_all_asks_only_for_changed_documents"] notes = "clean documents close; each changed one asks" [[feature]] @@ -634,7 +634,7 @@ milestone = "M4" status = "shipped" commands = ["file.revert"] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::revert_after_confirming", "crates/engine/src/tests.rs::revert_goes_back_to_the_saved_version"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::revert_after_confirming", "crates/engine/src/tests.rs::revert_goes_back_to_the_saved_version"] notes = "with confirmation; clears undo, as Acrobat" [[feature]] @@ -646,7 +646,7 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/engine/src/tests.rs::reload_reads_the_new_file_into_the_same_document", "crates/ui-egui/tests/reload.rs::a_rebuilt_file_reloads_in_place_keeping_the_page_and_zoom", "crates/ui-egui/tests/reload.rs::a_half_written_file_waits_until_the_pdf_is_whole", "crates/ui-egui/tests/reload.rs::saving_does_not_reload_or_lose_undo", "crates/ui-egui/tests/reload.rs::unsaved_changes_are_kept_until_reload_is_chosen", "crates/ui-egui/tests/reload.rs::a_deleted_file_leaves_the_document_open", "crates/ui-egui/tests/reload.rs::nothing_reloads_with_the_preference_off", "crates/ui-egui/tests/reload.rs::a_shorter_new_version_drops_what_pointed_into_the_old_one", "crates/ui-egui/tests/reload.rs::saving_over_a_file_changed_by_something_else_asks_first", "crates/ui-egui/tests/reload.rs::saving_checks_the_file_with_the_watcher_off_too", "crates/ui-egui/tests/reload.rs::opening_a_file_that_is_open_focuses_its_tab"] +evidence = ["crates/engine/src/tests.rs::reload_reads_the_new_file_into_the_same_document", "crates/ui-egui/tests/it/reload.rs::a_rebuilt_file_reloads_in_place_keeping_the_page_and_zoom", "crates/ui-egui/tests/it/reload.rs::a_half_written_file_waits_until_the_pdf_is_whole", "crates/ui-egui/tests/it/reload.rs::saving_does_not_reload_or_lose_undo", "crates/ui-egui/tests/it/reload.rs::unsaved_changes_are_kept_until_reload_is_chosen", "crates/ui-egui/tests/it/reload.rs::a_deleted_file_leaves_the_document_open", "crates/ui-egui/tests/it/reload.rs::nothing_reloads_with_the_preference_off", "crates/ui-egui/tests/it/reload.rs::a_shorter_new_version_drops_what_pointed_into_the_old_one", "crates/ui-egui/tests/it/reload.rs::saving_over_a_file_changed_by_something_else_asks_first", "crates/ui-egui/tests/it/reload.rs::saving_checks_the_file_with_the_watcher_off_too", "crates/ui-egui/tests/it/reload.rs::opening_a_file_that_is_open_focuses_its_tab"] notes = "Desktop, on by default (Preferences ▸ Documents and view, #431), for LaTeX/Typst builds: a worker thread checks each open file's metadata every 250 ms and reads a change once it has settled and ends in %%EOF; a clean document reloads in place (same tab, page, zoom, scroll; old page images stay until new ones are drawn), one with unsaved changes gets a Reload notice; PdfCraft's own saves never reload. Save asks before writing over a version of the file the document did not come from, watcher on or off, with Save as… to keep it (#2166); a file that is open already comes to the front instead of opening twice. Measured on Windows: one check 11 µs per file, reload of a 417-page 0.9 MB pdflatex file 16 ms. Not yet: OS change notifications (polling adds up to 250 ms), the web build; a reload clears undo history, like Revert." [[feature]] @@ -670,7 +670,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::dropping_a_pdf_on_the_window_opens_it", "crates/ui-egui/tests/ui.rs::dropping_an_open_pdf_again_focuses_its_tab"] +evidence = ["crates/ui-egui/tests/it/ui.rs::dropping_a_pdf_on_the_window_opens_it", "crates/ui-egui/tests/it/ui.rs::dropping_an_open_pdf_again_focuses_its_tab"] notes = "desktop: the dropped file opens in a tab (by path, or its bytes); web: read asynchronously; a drop with a filesystem path matching an open document focuses that tab (#1620); pathless bytes have nothing to match and open another tab" [[feature]] @@ -682,7 +682,7 @@ milestone = "M3" status = "shipped" commands = ["file.open_recent", "file.clear_recent"] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty", "crates/ui-egui/tests/commands.rs::clear_recent_files_at_the_foot_of_open_recent_empties_the_list", "crates/ui-egui/tests/commands.rs::clear_recent_files_runs_from_the_palette_and_is_saved", "crates/ui-egui/tests/ui.rs::home_removes_one_recent_file_or_clears_them_all"] +evidence = ["crates/ui-egui/tests/it/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/it/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/it/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty", "crates/ui-egui/tests/it/commands.rs::clear_recent_files_at_the_foot_of_open_recent_empties_the_list", "crates/ui-egui/tests/it/commands.rs::clear_recent_files_runs_from_the_palette_and_is_saved", "crates/ui-egui/tests/it/ui.rs::home_removes_one_recent_file_or_clears_them_all"] notes = "never reads other apps' lists; File ▸ Open Recent lists them (#290), an already-open file focuses its tab instead of reopening it, from any open path (#1620); Clear Recent Files at the foot of Open Recent, on Home and in the palette, and a remove button on each Home row (#430); missing: the row's remove button shows on pointer hover only (keyboard users clear the whole list)" [[feature]] @@ -694,7 +694,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::the_last_session_reopens_its_files_pages_and_active_tab", "crates/ui-egui/tests/editing.rs::quitting_with_unsaved_changes_still_remembers_every_file", "crates/ui-egui/tests/editing.rs::the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files", "crates/ui-egui/src/last_session.rs::malformed_sessions_are_skipped_and_capped"] +evidence = ["crates/ui-egui/tests/it/editing.rs::the_last_session_reopens_its_files_pages_and_active_tab", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_still_remembers_every_file", "crates/ui-egui/tests/it/editing.rs::the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files", "crates/ui-egui/src/last_session.rs::malformed_sessions_are_skipped_and_capped"] notes = "Desktop, off by default (Preferences ▸ Documents and view, #442): the files open when PdfCraft last closed reopen at the page each showed, with the same tab in front; files named on the command line open after them, in front; files that are gone, and documents the Recovery dialog offers, are skipped. Quitting with unsaved changes still remembers every tab. Not yet: zoom, layout and scroll position within the page (#418); only the first encrypted file asks for its password (later ones stay closed), and a file opened after it dismisses that prompt; a save that fails partway through a quit can leave the remembered list from that quit; never-saved documents and the web build have no paths to reopen." [[feature]] @@ -722,7 +722,7 @@ milestone = "M2" status = "partial" commands = [] tools = ["page_render"] -evidence = ["crates/render/src/raster.rs::renders_a_blue_rectangle", "crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/render/src/raster.rs::user_unit_scales_the_page_and_everything_on_it"] +evidence = ["crates/render/src/raster.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster.rs::user_unit_scales_the_page_and_everything_on_it"] notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value" [[feature]] @@ -758,7 +758,7 @@ milestone = "M11" status = "shipped" commands = [] tools = ["doc_save"] -evidence = ["crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +evidence = ["crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] notes = "full rewrites of signed documents are refused; saves stay incremental" [[feature]] @@ -842,7 +842,7 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::tiles_match_full_render", "crates/render/src/raster.rs::effective_scale_never_exceeds_the_caps", "crates/ui-egui/tests/view.rs::returning_to_a_visited_zoom_reuses_its_cached_tiles", "crates/ui-egui/tests/view.rs::zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive"] +evidence = ["crates/render/src/raster.rs::tiles_match_full_render", "crates/render/src/raster.rs::effective_scale_never_exceeds_the_caps", "crates/ui-egui/tests/it/view.rs::returning_to_a_visited_zoom_reuses_its_cached_tiles", "crates/ui-egui/tests/it/view.rs::zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive"] notes = "capped at 8192 px per side (M3.3); tiles of a zoom already rendered stay cached while their page is on screen, so zooming away and back shows the area sharp instead of re-rendering it tile by tile (#2196)" [[feature]] @@ -854,7 +854,7 @@ milestone = "M3" status = "shipped" commands = ["view.continuous", "view.page_gaps"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::page_gaps_in_organizer_do_not_move_the_document_scroll_position", "crates/ui-egui/tests/view.rs::page_gaps_comparison_menu_preserves_the_active_panes_choice", "crates/ui-egui/tests/view.rs::page_gaps_comparison_control_preserves_both_states", "crates/ui-egui/tests/view.rs::page_gaps_stay_closed_at_fractional_device_scales", "crates/ui-egui/tests/view.rs::page_gaps_toggle_joins_edges_without_changing_the_document", "crates/ui-egui/tests/view.rs::page_gaps_default_survives_restart_and_applies_to_new_documents", "crates/ui-egui/tests/view.rs::page_gaps_menu_joins_pages_without_a_shadow_at_the_seam", "crates/ui-egui/tests/view.rs::page_gaps_work_for_mixed_sizes_and_two_page_rows", "crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide", "crates/ui-egui/tests/view.rs::rapid_wheel_notches_accelerate_and_follow_touchpad_input", "crates/ui-egui/tests/view.rs::changing_continuous_layout_resets_wheel_acceleration"] +evidence = ["crates/ui-egui/tests/it/view.rs::page_gaps_in_organizer_do_not_move_the_document_scroll_position", "crates/ui-egui/tests/it/view.rs::page_gaps_comparison_menu_preserves_the_active_panes_choice", "crates/ui-egui/tests/it/view.rs::page_gaps_comparison_control_preserves_both_states", "crates/ui-egui/tests/it/view.rs::page_gaps_stay_closed_at_fractional_device_scales", "crates/ui-egui/tests/it/view.rs::page_gaps_toggle_joins_edges_without_changing_the_document", "crates/ui-egui/tests/it/view.rs::page_gaps_default_survives_restart_and_applies_to_new_documents", "crates/ui-egui/tests/it/view.rs::page_gaps_menu_joins_pages_without_a_shadow_at_the_seam", "crates/ui-egui/tests/it/view.rs::page_gaps_work_for_mixed_sizes_and_two_page_rows", "crates/ui-egui/tests/it/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/it/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/it/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/it/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide", "crates/ui-egui/tests/it/view.rs::rapid_wheel_notches_accelerate_and_follow_touchpad_input", "crates/ui-egui/tests/it/view.rs::changing_continuous_layout_resets_wheel_acceleration"] notes = "Page gaps, shadows and outlines can be hidden through the page-display menu, with a persisted default in Preferences and matching UI control options; page margins in the PDF are unchanged. Continuous scrolling remains the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes. Rapid line-wheel notches gain a bounded, constant-space arrival-rate boost (#1072); isolated notches still use the chosen Scroll speed, and page units stay unboosted. A touchpad swipe glides on after the fingers lift on Wayland (#531; macOS glides by itself; Windows and X11 report no lift through winit), unless Animation effects is off; a click, key, zoom or new scroll catches it. Not yet: fingers resting on the touchpad don't catch it (needs Wayland hold gestures, which the vendored winit doesn't bind)" [[feature]] @@ -866,7 +866,7 @@ milestone = "M3" status = "shipped" commands = ["view.layout.single"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/view.rs::single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back", "crates/ui-egui/tests/view.rs::single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does", "crates/ui-egui/tests/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/view.rs::view_menu_page_display_uses_the_same_commands"] +evidence = ["crates/ui-egui/tests/it/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/it/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/it/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/it/view.rs::single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back", "crates/ui-egui/tests/it/view.rs::single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does", "crates/ui-egui/tests/it/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/it/view.rs::view_menu_page_display_uses_the_same_commands"] notes = "when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in so the page is taller than the window, the wheel pans, and at the bottom (top) edge a wheel notch, or a fresh trackpad swipe, shows the next page at its top (the previous page at its bottom); a swipe that panned to the edge, momentum included, does not turn until the fingers lift and swipe again (#1924). Not verified against Acrobat's own thresholds. Switched from the View menu, the rail's Page display button or the palette" [[feature]] @@ -878,7 +878,7 @@ milestone = "M3" status = "shipped" commands = ["view.layout.two_up"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::two_up_layout_places_pages_side_by_side", "crates/ui-egui/tests/viewer.rs::two_page_view_steps_a_spread_at_a_time"] +evidence = ["crates/ui-egui/tests/it/view.rs::two_up_layout_places_pages_side_by_side", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time"] notes = "next/previous page step a spread at a time" [[feature]] @@ -890,7 +890,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::layouts_fit_height_labels_and_system_theme"] notes = "the two-page view scrolls continuously" [[feature]] @@ -902,7 +902,7 @@ milestone = "M3" status = "shipped" commands = ["view.layout.cover"] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/viewer.rs::page_down_steps_every_spread_when_several_fit_on_screen"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::layouts_fit_height_labels_and_system_theme", "crates/ui-egui/tests/it/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/it/viewer.rs::page_down_steps_every_spread_when_several_fit_on_screen"] notes = "View ▸ Page display, the rail's Page display button or the palette; disabled outside two-page view; next / previous page step one spread at a time even when several spreads fit on screen" [[feature]] @@ -926,7 +926,7 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views", "crates/ui-egui/tests/control.rs::middle_button_pans_while_held_outside_linux", "crates/ui-egui/tests/comments.rs::a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing"] +evidence = ["crates/ui-egui/tests/it/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/it/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/it/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/it/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views", "crates/ui-egui/tests/it/control.rs::middle_button_pans_while_held_outside_linux", "crates/ui-egui/tests/it/comments.rs::a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing"] notes = "Linux-only middle-button vertical autoscroll in the document and Organize Pages; Windows and macOS pan 1:1 while the middle button is held (Grabbing cursor, no latch). On every target the middle button never reaches the page tools, so it scrolls instead of drawing, selecting, cropping or moving. Click to toggle, release keeps scrolling on, and a Chromium-style nonlinear curve increases speed with vertical screen distance from the initial click. A 15-point dead zone pauses immediately. Continuous redraws use elapsed frame time and preserve fractional movement, tested at 30/60/120/144 fps. Cancels on another middle click, Escape, other pointer/wheel input, focus loss, tab changes and dialogs. Driven headlessly through the opt-in UI control channel (ui.click/ui.move/ui.drag). Keyboard-started timed scrolling is not implemented." [[feature]] @@ -938,7 +938,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::zoom_keeps_the_point_under_the_cursor_still", "crates/ui-egui/tests/ui.rs::trackpad_pinch_zooms_the_pdf_under_the_pointer"] +evidence = ["crates/ui-egui/tests/it/ui.rs::zoom_keeps_the_point_under_the_cursor_still", "crates/ui-egui/tests/it/ui.rs::trackpad_pinch_zooms_the_pdf_under_the_pointer"] notes = "A trackpad pinch zooms the PDF beneath the pointer in either direction, keeping the same page point in place. The viewer handles the platform's zoom gesture where it is available." [[feature]] @@ -950,7 +950,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/view.rs::the_rail_zoom_field_sets_a_typed_zoom"] +evidence = ["crates/ui-egui/tests/it/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/it/view.rs::the_rail_zoom_field_sets_a_typed_zoom"] notes = "Typed into the right rail's zoom field (150, 150% or 62.5 %, Enter applies, Escape discards, clamped to 8–6400%) or set through the control channel's zoom option; no preset dropdown in the field yet. PdfCraft draws a percentage on its fixed 96-dpi basis: 96/72 logical pixels per PDF point at 100%. Acrobat draws it at its Page Display resolution, with logical pixels per PDF point = ppi / 72 × zoom (checked black-box with Custom resolutions of 96, 110 and 120 ppi on one display), so the same percentage can differ in size. Acrobat's Use System Setting resolution is not reproduced. Tracked with misc.prefs-page-display" [[feature]] @@ -962,7 +962,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page"] +evidence = ["crates/ui-egui/tests/it/view.rs::actual_size_fit_width_and_fit_page"] notes = "Actual size is 100% on PdfCraft's fixed 96-dpi basis: 96/72 logical pixels per PDF point. Acrobat's Actual Size uses its Page Display resolution (ppi / 72 logical pixels per PDF point at 100%), which PdfCraft does not offer yet, so the two can draw a page at different sizes. Tracked with misc.prefs-page-display" [[feature]] @@ -974,7 +974,7 @@ milestone = "M3" status = "shipped" commands = ["view.fit_one_page"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page", "crates/ui-egui/tests/view.rs::rail_page_display_menu_offers_acrobats_view_choices", "crates/ui-egui/tests/view.rs::fit_page_holds_zoom_across_mixed_page_sizes", "crates/ui-egui/tests/view.rs::single_page_fit_page_fits_the_shown_page"] +evidence = ["crates/ui-egui/tests/it/view.rs::actual_size_fit_width_and_fit_page", "crates/ui-egui/tests/it/view.rs::rail_page_display_menu_offers_acrobats_view_choices", "crates/ui-egui/tests/it/view.rs::fit_page_holds_zoom_across_mixed_page_sizes", "crates/ui-egui/tests/it/view.rs::single_page_fit_page_fits_the_shown_page"] notes = "View ▸ Zoom to page level (⌘0) and the rail's Page display menu, which also offers Fit one full page (single-page view at Fit page). Scrolling views fit their largest page so mixed sizes never jump the zoom; single-page view fits the shown page" [[feature]] @@ -986,7 +986,7 @@ milestone = "M3" status = "shipped" commands = ["view.fit_width_scrolling"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page", "crates/ui-egui/tests/view.rs::rail_page_display_menu_offers_acrobats_view_choices"] +evidence = ["crates/ui-egui/tests/it/view.rs::actual_size_fit_width_and_fit_page", "crates/ui-egui/tests/it/view.rs::rail_page_display_menu_offers_acrobats_view_choices"] notes = "View ▸ Fit to width (⌘2) and the rail's Page display menu's Fit to width scrolling (continuous scrolling at Fit width)" [[feature]] @@ -998,7 +998,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::layouts_fit_height_labels_and_system_theme"] notes = "View ▸ Fit to height" [[feature]] @@ -1010,7 +1010,7 @@ milestone = "M3" status = "shipped" commands = ["view.fit_visible"] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::fit_visible_zooms_to_the_content_width", "crates/ui-egui/src/zoom_snap.rs::ink_bounds_finds_the_marked_area"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::fit_visible_zooms_to_the_content_width", "crates/ui-egui/src/zoom_snap.rs::ink_bounds_finds_the_marked_area"] notes = "View ▸ Zoom ▸ Fit visible (⌘3): the current page's inked area (found by a quick low-resolution render) spans the window width; respects view rotation; drivable via the control channel's command" [[feature]] @@ -1022,7 +1022,7 @@ milestone = "M3" status = "shipped" commands = ["view.marquee_zoom"] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::marquee_zoom_and_snapshot"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::marquee_zoom_and_snapshot"] notes = "drag to fill the window with an area; click to zoom in" [[feature]] @@ -1070,7 +1070,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging", "crates/ui-egui/tests/view.rs::the_hand_tool_moves_the_page_with_the_pointer", "crates/ui-egui/tests/viewer.rs::v_h_and_space_pick_the_quick_tools"] +evidence = ["crates/ui-egui/tests/it/view.rs::the_hand_tool_pans_by_dragging", "crates/ui-egui/tests/it/view.rs::the_hand_tool_moves_the_page_with_the_pointer", "crates/ui-egui/tests/it/viewer.rs::v_h_and_space_pick_the_quick_tools"] notes = "drag to pan: the page follows the pointer point for point in the frame it moves, and stops where it is let go (no fling); Grab/Grabbing cursors; H picks it, V goes back to Select, Space held pans temporarily. Checked with kittest pointer events, not yet on a desktop" [[feature]] @@ -1082,7 +1082,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation"] +evidence = ["crates/ui-egui/tests/it/ui.rs::selection_works_in_every_view_rotation"] notes = "" [[feature]] @@ -1094,7 +1094,7 @@ milestone = "M3" status = "shipped" commands = ["view.read_mode"] tools = [] -evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented", "crates/ui-egui/tests/ui.rs::the_mode_bar_has_no_read_tab", "crates/ui-egui/tests/view.rs::switching_modes_keeps_the_current_page_at_fixed_zoom"] +evidence = ["crates/ui-egui/tests/it/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/it/commands.rs::every_registered_command_is_implemented", "crates/ui-egui/tests/it/ui.rs::the_mode_bar_has_no_read_tab", "crates/ui-egui/tests/it/view.rs::switching_modes_keeps_the_current_page_at_fixed_zoom"] notes = "View ▸ Read mode (and the Read mode workspace) hides the panes and tool chrome. The mode bar has no Read tab, as in Acrobat, where reading is the viewer with no tool in use: its tabs are All tools, Edit, Convert and E-Sign. Switching between Read and the docking workspace preserves the document’s current scroll position at fixed zoom; each tab keeps its own position" [[feature]] @@ -1106,7 +1106,7 @@ milestone = "M3" status = "shipped" commands = ["view.full_screen"] tools = [] -evidence = ["crates/ui-egui/tests/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves", "crates/ui-egui/tests/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it", "crates/ui-egui/tests/view.rs::full_screen_keeps_two_page_view_as_spreads"] +evidence = ["crates/ui-egui/tests/it/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves", "crates/ui-egui/tests/it/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it", "crates/ui-egui/tests/it/view.rs::full_screen_keeps_two_page_view_as_spreads"] notes = "Acrobat's Full Screen Mode (#1459): the page on black and nothing else, one page at a time fitted to the screen (one spread when two-page view was on, #1353), from the current page; a click, Space, Enter, \u2192 or Page Down go forward, a shift-click, Backspace, \u2190 or Page Up back, Home/End to the ends; Esc or \u2318L bring the display from before back at the page reached. Links still open on click. Missing: the cursor is not hidden, no transitions, auto-advance or loop (view.full-screen-presentation), and no full-screen preferences such as the background colour" [[feature]] @@ -1118,7 +1118,7 @@ milestone = "M12" status = "partial" commands = ["view.full_screen"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it"] +evidence = ["crates/ui-egui/tests/it/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it"] notes = "Click to advance (and shift-click back) ships with view.full-screen; auto-advance every N seconds, loop after the last page, page transitions and the full-screen preferences are not done" [[feature]] @@ -1142,7 +1142,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document"] +evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document"] notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout, ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel" [[feature]] @@ -1154,7 +1154,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::layouts_fit_height_labels_and_system_theme"] notes = "the page box shows the current page's label and takes labels (iv, A-1) before numbers; where the label is not the page number the rail shows the physical position under it (3/180, dropped to the page count when it does not fit). Not yet: a preference to show physical numbers only, and labels in the organize bar's Page n of m" [[feature]] @@ -1166,7 +1166,7 @@ milestone = "M3" status = "shipped" commands = ["view.previous_view", "view.next_view"] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/tests/control.rs::navigation_history_commands_and_rail_are_per_tab_and_non_editing", "crates/ui-egui/tests/control.rs::routine_continuous_scrolling_does_not_add_navigation_history"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/tests/it/control.rs::navigation_history_commands_and_rail_are_per_tab_and_non_editing", "crates/ui-egui/tests/it/control.rs::routine_continuous_scrolling_does_not_add_navigation_history"] notes = "Per-tab page history: explicit search-result jumps, page navigation, bookmarks and links; no continuous-scroll history or restart persistence; existing page-step history is retained. Rail buttons, View ▸ Page navigation and Ctrl/⌘[ / Ctrl/⌘]. UI control ui.command exposes view.previous_view / view.next_view; standalone engine sessions have no viewport history. Restores page tops, not exact scroll/zoom positions." [[feature]] @@ -1178,7 +1178,7 @@ milestone = "M3" status = "shipped" commands = [] tools = ["link_open"] -evidence = ["crates/ui-egui/tests/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/view.rs::link_destinations_scroll_to_their_position", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords"] +evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords"] notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. URL/network file systems, GoToE, Launch and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit." [[feature]] @@ -1190,7 +1190,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/comments.rs::hovering_a_comment_shows_its_author_and_text", "crates/ui-egui/tests/comments.rs::multiline_markup_hover_stays_on_marked_lines", "crates/ui-egui/tests/comments.rs::neighboring_markup_hover_changes_to_its_own_comment", "crates/ui-egui/tests/comments.rs::overlapping_markup_hover_shows_the_topmost_comment", "crates/ui-egui/tests/comments.rs::overlapping_markup_hover_uses_annotation_index_with_different_top_edges", "crates/ui-egui/tests/comments.rs::markup_hover_without_quads_keeps_the_rectangle_fallback", "crates/ui-egui/tests/comments.rs::markup_hover_skips_popup_containers", "crates/ui-egui/tests/comments.rs::preference_hides_author_only_highlight_hover_previews"] +evidence = ["crates/ui-egui/tests/it/comments.rs::hovering_a_comment_shows_its_author_and_text", "crates/ui-egui/tests/it/comments.rs::multiline_markup_hover_stays_on_marked_lines", "crates/ui-egui/tests/it/comments.rs::neighboring_markup_hover_changes_to_its_own_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_shows_the_topmost_comment", "crates/ui-egui/tests/it/comments.rs::overlapping_markup_hover_uses_annotation_index_with_different_top_edges", "crates/ui-egui/tests/it/comments.rs::markup_hover_without_quads_keeps_the_rectangle_fallback", "crates/ui-egui/tests/it/comments.rs::markup_hover_skips_popup_containers", "crates/ui-egui/tests/it/comments.rs::preference_hides_author_only_highlight_hover_previews"] notes = "A popup with the author and comment text. Text markup uses selection's per-quad screen bounds, including page and view rotation, so unmarked parts of a multiline Rect do not claim the hover (#3541). The last-painted eligible comment wins where annotations overlap (#3620), using the original /Annots index independently of spatial inspection order. Missing complete quads retain the Rect fallback; author-only highlight hiding and dialog suppression remain. Bounds are rectangular, not exact hit testing of skewed quadrilaterals. Pointer feedback is UI-only, reachable through the opt-in control channel, with no document automation tool." [[feature]] @@ -1202,7 +1202,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/control.rs::page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant", "crates/ui-egui/src/canvas.rs::page_size_hint_zone_is_the_bottom_left_quadrant"] +evidence = ["crates/ui-egui/tests/it/control.rs::page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant", "crates/ui-egui/src/canvas.rs::page_size_hint_zone_is_the_bottom_left_quadrant"] notes = "While the pointer is in the bottom-left quadrant of the page view, a small label shows the current page's size (after /Rotate and /UserUnit) in the Preferences unit, formatted as in Document Properties (#1855). It never takes clicks and hides when the pointer leaves. Acrobat's trigger is the corner itself and may differ in exact area and wording (not compared with real Acrobat); the size of the current page only, not of the page under the pointer; no headless tool (page sizes are in document info)." [[feature]] @@ -1214,7 +1214,7 @@ milestone = "M3" status = "shipped" commands = ["view.next_tab", "view.previous_tab"] tools = ["doc_list"] -evidence = ["crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/viewer.rs::each_document_keeps_its_own_scroll_position", "crates/ui-egui/tests/viewer.rs::a_new_document_opens_at_the_top_after_a_relaunch", "crates/ui-egui/tests/ui.rs::dragging_a_tab_moves_it_and_keeps_the_same_document_active", "crates/ui-egui/tests/ui.rs::a_tab_dropped_where_it_was_stays_and_a_click_still_selects_it", "crates/ui-egui/tests/ui.rs::the_combine_tab_does_not_move_and_documents_stay_in_order", "crates/ui-egui/tests/commands.rs::ctrl_tab_steps_through_the_tabs_in_order_and_wraps_around"] +evidence = ["crates/ui-egui/tests/it/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/viewer.rs::each_document_keeps_its_own_scroll_position", "crates/ui-egui/tests/it/viewer.rs::a_new_document_opens_at_the_top_after_a_relaunch", "crates/ui-egui/tests/it/ui.rs::dragging_a_tab_moves_it_and_keeps_the_same_document_active", "crates/ui-egui/tests/it/ui.rs::a_tab_dropped_where_it_was_stays_and_a_click_still_selects_it", "crates/ui-egui/tests/it/ui.rs::the_combine_tab_does_not_move_and_documents_stay_in_order", "crates/ui-egui/tests/it/commands.rs::ctrl_tab_steps_through_the_tabs_in_order_and_wraps_around"] notes = "each tab keeps its own scroll position (#189), and a newly opened one starts at the top, not where an earlier session left the scroll. Document tabs are dragged to reorder them (#1537): a line marks where the tab will land, the strip scrolls at its edges when it overflows, and the same document stays active; the Combine files tab stays after the documents. Dragging a tab out into its own window is view.multiple-windows. Ctrl+Tab and Ctrl+Shift+Tab (⌃Tab and ⌃⇧Tab on macOS; View ▸ Next tab / Previous tab) step through the tabs in their order on screen, Combine files included, and wrap around at either end (#1542)." [[feature]] @@ -1262,7 +1262,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnail_zoom_is_bounded_independent_and_survives_reload", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnail_zoom_keeps_a_far_current_page_visible", "crates/ui-egui/tests/control.rs::thumbnail_size_is_settable_and_inspectable_through_the_control_channel", "crates/ui-egui/tests/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"] +evidence = ["crates/ui-egui/tests/it/editing.rs::resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_is_bounded_independent_and_survives_reload", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_keeps_a_far_current_page_visible", "crates/ui-egui/tests/it/control.rs::thumbnail_size_is_settable_and_inspectable_through_the_control_channel", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/it/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/it/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"] notes = "Thumbnail size: 50–150% with a slider and buttons above the scroll area (#3808), independent of document and Organize zoom. Accessible through ui.set thumbnail-zoom and ui.state active.thumbnail_zoom; retained on file reload, not persisted between sessions. The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it." [[feature]] @@ -1274,7 +1274,7 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"] +evidence = ["crates/ui-egui/tests/it/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/it/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/it/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"] notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." [[feature]] @@ -1286,7 +1286,7 @@ milestone = "M3" status = "shipped" commands = [] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/view.rs::bookmark_destinations_scroll_to_their_position"] +evidence = ["crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/view.rs::bookmark_destinations_scroll_to_their_position"] notes = "A bookmark goes to its destination's position and zoom, not just its page. The panel shows the first 10,000 bookmarks, nested 33 levels deep at most, and says so; bookmark_list pages through every bookmark in the file (up to 33 levels deep)." [[feature]] @@ -1322,7 +1322,7 @@ milestone = "M3" status = "shipped" commands = [] tools = ["doc_info", "layer_set"] -evidence = ["crates/render/src/raster.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits", "crates/automation/tests/automation.rs::layers_and_hidden_comments_change_what_page_render_draws"] +evidence = ["crates/render/src/raster.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits", "crates/automation/tests/it/automation.rs::layers_and_hidden_comments_change_what_page_render_draws"] notes = "layer_set shows or hides a layer by name (every group with that name, as CAD exporters repeat names) or by position, and page_render draws with the document's layer visibility. Still missing: Reset to initial visibility (view.layers-reset), layer properties, and /OC on annotations (#2176)." [[feature]] @@ -1382,7 +1382,7 @@ milestone = "M3" status = "shipped" commands = [] tools = ["doc_info"] -evidence = ["crates/render/src/inspect.rs::roman_and_alpha_labels", "crates/automation/tests/automation.rs::open_inspect_render_and_find"] +evidence = ["crates/render/src/inspect.rs::roman_and_alpha_labels", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find"] notes = "" [[feature]] @@ -1394,7 +1394,7 @@ milestone = "M3" status = "shipped" commands = ["edit.find"] tools = ["text_find"] -evidence = ["crates/ui-egui/tests/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/ui.rs::find_reports_no_matches", "crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/render/src/text.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text.rs::short_tracked_ellipses_remain_searchable"] +evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text.rs::short_tracked_ellipses_remain_searchable"] notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Dotted TOC leaders preserve word spaces for phrase search (#3086), while short tracked ellipses remain intact and searchable; sparse lines and condensed OCR gaps below the existing height-based threshold remain limitations. Complex interleaved flows still use a geometric reading-order heuristic." [[feature]] @@ -1406,7 +1406,7 @@ milestone = "M3" status = "shipped" commands = ["edit.find"] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::find_counts_matches_across_pages"] +evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages"] notes = "" [[feature]] @@ -1418,7 +1418,7 @@ milestone = "M3" status = "shipped" commands = ["edit.find"] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options"] notes = "whole words only, case-sensitive" [[feature]] @@ -1442,7 +1442,7 @@ milestone = "M3" status = "shipped" commands = ["edit.advanced_search"] tools = ["text_find"] -evidence = ["crates/ui-egui/tests/viewer.rs::advanced_search_lists_results_with_context"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::advanced_search_lists_results_with_context"] notes = "Search panel (⇧⌘F): every match grouped by page with its context, whole words / case options, click to go; shares the find bar's search (closing the panel moves it to the bar)" [[feature]] @@ -1454,7 +1454,7 @@ milestone = "M12" status = "partial" commands = ["edit.advanced_search"] tools = ["text_find"] -evidence = ["crates/ui-egui/tests/viewer.rs::advanced_search_lists_results_with_context"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::advanced_search_lists_results_with_context"] notes = "in the current document, as a side panel; searching other open documents, folders and indexes is still to come" [[feature]] @@ -1514,7 +1514,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/ui_group/text_select.rs::quick_clicks_select_a_word_then_the_line_then_the_page", "crates/ui-egui/tests/ui_group/text_select.rs::quick_clicks_with_the_highlighter_mark_only_the_word", "crates/ui-egui/tests/ui_group/text_select.rs::shift_click_extends_the_selection_from_its_anchor", "crates/ui-egui/tests/comments.rs::selected_text_shows_actions_and_underlines_without_changing_tools", "crates/ui-egui/tests/comments.rs::selected_text_replace_action_opens_replacement_composer", "crates/ui-egui/tests/comments.rs::selected_text_comment_action_opens_note_composer"] +evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/it/text_select.rs::quick_clicks_select_a_word_then_the_line_then_the_page", "crates/ui-egui/tests/it/text_select.rs::quick_clicks_with_the_highlighter_mark_only_the_word", "crates/ui-egui/tests/it/text_select.rs::shift_click_extends_the_selection_from_its_anchor", "crates/ui-egui/tests/it/comments.rs::selected_text_shows_actions_and_underlines_without_changing_tools", "crates/ui-egui/tests/it/comments.rs::selected_text_replace_action_opens_replacement_composer", "crates/ui-egui/tests/it/comments.rs::selected_text_comment_action_opens_note_composer"] notes = "Drag to select; ⇧-click extends the selection to the click, keeping its anchor (same page only); with the Select tool, quick clicks select a word (two), the line (three) or all the text on the page (four). A floating action bar offers comment, highlight, underline, strikethrough, replace text and copy after selection. The markup and Redact tools mark a double-clicked word and stop there. Missing: selection across pages (dragging or ⇧-clicking onto another page), paragraph selection, and ⌘A across the whole document (it selects the current page). Acrobat's other selection context actions such as bookmark, link and export selection remain outside this bar." [[feature]] @@ -1526,7 +1526,7 @@ milestone = "M3" status = "shipped" commands = ["edit.column_select"] tools = ["text_extract"] -evidence = ["crates/render/src/text.rs::a_column_selection_takes_one_table_column", "crates/render/src/text.rs::a_column_selection_across_columns_reads_row_by_row", "crates/render/src/text.rs::right_to_left_rows_read_right_to_left", "crates/render/src/text.rs::vertical_lines_keep_reading_order", "crates/ui-egui/tests/ui_group/column_select.rs::alt_drag_selects_only_the_text_inside_the_rectangle", "crates/ui-egui/tests/ui_group/column_select.rs::the_column_select_tool_needs_no_modifier", "crates/ui-egui/tests/ui_group/column_select.rs::alt_drag_with_the_highlighter_marks_only_the_column", "crates/automation/tests/automation.rs::text_extract_with_a_rect_takes_one_column"] +evidence = ["crates/render/src/text.rs::a_column_selection_takes_one_table_column", "crates/render/src/text.rs::a_column_selection_across_columns_reads_row_by_row", "crates/render/src/text.rs::right_to_left_rows_read_right_to_left", "crates/render/src/text.rs::vertical_lines_keep_reading_order", "crates/ui-egui/tests/it/column_select.rs::alt_drag_selects_only_the_text_inside_the_rectangle", "crates/ui-egui/tests/it/column_select.rs::the_column_select_tool_needs_no_modifier", "crates/ui-egui/tests/it/column_select.rs::alt_drag_with_the_highlighter_marks_only_the_column", "crates/automation/tests/it/automation.rs::text_extract_with_a_rect_takes_one_column"] notes = "Issue #740. Holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle drawn, with the Select tool, the text markup tools and Redact (with Highlight and Redact the drag must start on text, since off text they draw an area box). Edit ▸ Column select does the same with a plain drag. A glyph counts when its centre is inside. Copy gives one row per visual line, top to bottom; pieces side by side (table cells) are separated by a tab, right to left in right-to-left rows; vertical text keeps reading order. text_extract takes the same rectangle as rect. Platforms: on Linux desktops whose window manager moves windows with Alt-drag (Xfce, Cinnamon and MATE by default; KDE Plasma 5 and other setups with Alt as the window modifier; GNOME and Plasma 6 use Super) the drag never reaches PdfCraft, so use Edit ▸ Column select or change that setting. On Windows, releasing Alt does not open a system menu (the window has no native menu). The web build has the same Linux caveat in the browser. Checked by hand on Linux (Fedora) with the Column select tool; Alt/Option-drag, and macOS and Windows generally, rest on the kittest tests. Missing: selection across pages, ⇧-click extending a column, a default shortcut, and a check of Acrobat's own copied-text format (not observed)." [[feature]] @@ -1538,7 +1538,7 @@ milestone = "M3" status = "shipped" commands = ["edit.copy"] tools = ["text_extract"] -evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership"] +evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership"] notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract." [[feature]] @@ -1550,7 +1550,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options"] notes = "⌘A on the current page" [[feature]] @@ -1574,7 +1574,7 @@ milestone = "M3" status = "shipped" commands = ["edit.snapshot"] tools = ["page_render"] -evidence = ["crates/ui-egui/tests/viewer.rs::marquee_zoom_and_snapshot"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::marquee_zoom_and_snapshot"] notes = "copies the area at the current zoom to the clipboard as an image" [[feature]] @@ -1586,7 +1586,7 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/it/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/it/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text"] notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000/JBIG2/CCITT images, multi-format clipboard (file and bitmap at once), the web build" [[feature]] @@ -1658,7 +1658,7 @@ milestone = "M3" status = "shipped" commands = ["view.theme", "view.theme.light", "view.theme.dark"] tools = [] -evidence = ["crates/ui-egui/tests/ui_group/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/src/theme.rs::text_is_readable_on_every_surface"] +evidence = ["crates/ui-egui/tests/it/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/src/theme.rs::text_is_readable_on_every_surface"] notes = "Toolbar and Display theme share three choices and a preference setter; manual choices exit system mode. Every text colour meets WCAG AA (4.5:1) on every surface in both themes; glyphs are pixel-aligned for sharper text at 100–150 % scaling." [[feature]] @@ -1670,7 +1670,7 @@ milestone = "M3" status = "shipped" commands = ["view.theme.system"] tools = [] -evidence = ["crates/ui-egui/tests/ui_group/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/tests/ui_group/theme.rs::saved_preferences_restore_with_legacy_light_dark_settings", "crates/ui-egui/tests/control.rs::theme_commands_and_options_report_preference_and_effective_colours"] +evidence = ["crates/ui-egui/tests/it/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/tests/it/theme.rs::saved_preferences_restore_with_legacy_light_dark_settings", "crates/ui-egui/tests/it/control.rs::theme_commands_and_options_report_preference_and_effective_colours"] notes = "Toolbar, Display theme and --theme system share a persisted System preference that follows OS changes without losing the selected mode; ui.state reports preference and resolved colours separately." [[feature]] @@ -1682,7 +1682,7 @@ milestone = "M3" status = "shipped" commands = ["file.pin_folder"] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::home_shows_welcome_and_tools", "crates/ui-egui/tests/folders.rs::a_pinned_folder_lists_its_newest_pdfs_on_home", "crates/ui-egui/tests/folders.rs::clicking_a_pinned_pdf_opens_it_and_adds_it_to_recent", "crates/ui-egui/tests/folders.rs::pinned_folders_survive_a_restart"] +evidence = ["crates/ui-egui/tests/it/ui.rs::home_shows_welcome_and_tools", "crates/ui-egui/tests/it/folders.rs::a_pinned_folder_lists_its_newest_pdfs_on_home", "crates/ui-egui/tests/it/folders.rs::clicking_a_pinned_pdf_opens_it_and_adds_it_to_recent", "crates/ui-egui/tests/it/folders.rs::pinned_folders_survive_a_restart"] notes = "Desktop: folders pinned to Home (File ▸ Pin folder to Home…, up to 8) list their newest PDFs, read on a worker thread and refreshed every few seconds while Home shows; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Pinned folders are local paths, so a cloud drive works through its synced folder (iCloud Drive, OneDrive, Dropbox). Not yet: subfolders, search within a pinned folder, the web build." [[feature]] @@ -1694,7 +1694,7 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::every_catalog_tool_is_listed_after_view_more", "crates/engine/src/commands.rs::every_ready_catalogue_item_is_a_registered_command"] +evidence = ["crates/ui-egui/tests/it/ui.rs::every_catalog_tool_is_listed_after_view_more", "crates/engine/src/commands.rs::every_ready_catalogue_item_is_a_registered_command"] notes = "" [[feature]] @@ -1706,7 +1706,7 @@ milestone = "M3" status = "shipped" commands = ["view.palette"] tools = [] -evidence = ["crates/ui-egui/tests/commands.rs::the_palette_lists_commands_with_shortcuts_and_runs_them"] +evidence = ["crates/ui-egui/tests/it/commands.rs::the_palette_lists_commands_with_shortcuts_and_runs_them"] notes = "" [[feature]] @@ -1718,7 +1718,7 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/commands.rs::shortcuts_run_registered_commands", "crates/engine/src/commands.rs::shortcut_labels_follow_platform_conventions", "crates/ui-egui/tests/commands.rs::quit_shortcut_requires_exact_modifiers_and_preserves_unsaved_work", "crates/ui-egui/tests/signing.rs::ctrl_alt_q_types_an_email_without_quitting"] +evidence = ["crates/ui-egui/tests/it/commands.rs::shortcuts_run_registered_commands", "crates/engine/src/commands.rs::shortcut_labels_follow_platform_conventions", "crates/ui-egui/tests/it/commands.rs::quit_shortcut_requires_exact_modifiers_and_preserves_unsaved_work", "crates/ui-egui/tests/it/signing.rs::ctrl_alt_q_types_an_email_without_quitting"] notes = "Quit requires exact Ctrl+Q/Cmd+Q modifiers and preserves unsaved-document confirmation. Ctrl+Alt+Q email input is covered with synthetic key/text events; native German keyboard-layout translation is not covered by that test (#1980)." [[feature]] @@ -1730,7 +1730,7 @@ milestone = "M4" status = "shipped" commands = ["help.shortcuts"] tools = [] -evidence = ["crates/ui-egui/tests/commands.rs::the_shortcuts_dialog_lists_the_real_bindings"] +evidence = ["crates/ui-egui/tests/it/commands.rs::the_shortcuts_dialog_lists_the_real_bindings"] notes = "" [[feature]] @@ -1742,7 +1742,7 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/commands.rs::the_pages_menu_comes_from_the_registry"] +evidence = ["crates/ui-egui/tests/it/commands.rs::the_pages_menu_comes_from_the_registry"] notes = "" [[feature]] @@ -1754,7 +1754,7 @@ milestone = "M3" status = "shipped" commands = ["help.about"] tools = [] -evidence = ["crates/ui-egui/tests/commands.rs::the_about_dialog_shows_the_running_version"] +evidence = ["crates/ui-egui/tests/it/commands.rs::the_about_dialog_shows_the_running_version"] notes = "" [[feature]] @@ -1766,7 +1766,7 @@ milestone = "M3" status = "partial" commands = ["help.check_updates"] tools = [] -evidence = ["crates/ui-egui/tests/ui_group/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/ui_group/updates.rs::nothing_is_asked_until_the_user_checks", "apps/pdfcraft/src/updates.rs::answers_are_read_and_only_our_release_pages_are_offered"] +evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks", "apps/pdfcraft/src/updates.rs::answers_are_read_and_only_our_release_pages_are_offered"] notes = "asks GitHub Releases on request and offers the release page. By the owner's decision there is no check at start and no in-place install (Acrobat has both)" [[feature]] @@ -1802,7 +1802,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/ui-egui/tests/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/ui_group/xfa.rs::a_secured_dynamic_xfa_form_shows_its_layout_and_its_security"] +evidence = ["crates/ui-egui/tests/it/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/it/xfa.rs::a_secured_dynamic_xfa_form_shows_its_layout_and_its_security"] notes = "On a secured form (fields or XFA), the security sentence follows the form's notice, which keeps Highlight fields and the XFA layout notes." [[feature]] @@ -1838,7 +1838,7 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/pickers.rs::a_startup_url_opens_in_front_when_the_user_has_not_started_working", "crates/ui-egui/tests/pickers.rs::a_late_startup_url_preserves_the_current_edit_workspace_and_save_target", "crates/ui-egui/tests/pickers.rs::deliberate_browser_file_opens_still_activate_the_new_tab"] +evidence = ["crates/ui-egui/tests/it/pickers.rs::a_startup_url_opens_in_front_when_the_user_has_not_started_working", "crates/ui-egui/tests/it/pickers.rs::a_late_startup_url_preserves_the_current_edit_workspace_and_save_target", "crates/ui-egui/tests/it/pickers.rs::deliberate_browser_file_opens_still_activate_the_new_tab"] notes = "web build works; a late startup ?file= download opens in a background tab after the user starts working, preserving the active document and Save target (#171). IndexedDB/OPFS recent list and download flow are M3.10" [[feature]] @@ -1866,7 +1866,7 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools"] +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools"] notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" [[feature]] @@ -1878,7 +1878,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::japanese_fallback_reports_the_face_after_save_and_reopen", "crates/edit/src/tests.rs::japanese_paragraph_keeps_ideographic_spaces", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] +evidence = ["crates/edit/src/tests.rs::japanese_fallback_reports_the_face_after_save_and_reopen", "crates/edit/src/tests.rs::japanese_paragraph_keeps_ideographic_spaces", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] notes = "edited paragraphs are rewrapped to the box width with the font's own advance widths and the original line spacing, starting at the original text matrix; text state and fill colour restored after; a single line grows to the right margin; U+3000 ideographic spaces are preserved (including paragraph edges and repeated spaces); Japanese Type 3 fallback reports its face through FontDescriptor/FontName after editing and save/reopen; source family/weight matching remains limited (#184)" [[feature]] @@ -1890,7 +1890,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/ui-egui/tests/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] +evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/ui-egui/tests/it/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/it/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/it/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/it/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] notes = "existing paragraphs: drag the box to move it, drag the handle on its left or right edge to rewrap it to a new width (upright pages), including the half of the handle outside the box, which started an area selection instead (#2276); the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)" [[feature]] @@ -1902,7 +1902,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_paragraphs"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; hyphenated line ends are joined; a row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it" [[feature]] @@ -1926,7 +1926,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text", "edit.text"] tools = ["text_edit", "content_update"] -evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] +evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] notes = "existing paragraphs and added text: font family (Helvetica, Times, Courier) with bold and italic, size, colour; existing paragraphs are rewrapped" [[feature]] @@ -1938,7 +1938,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] +evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] notes = "bold and italic through the standard families; underline drawn under each line in the text colour" [[feature]] @@ -1962,7 +1962,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text", "edit.text"] tools = ["text_edit", "content_update"] -evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/edit/src/tests.rs::a_one_row_title_keeps_its_centre_when_it_grows_and_when_it_wraps", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] +evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/edit/src/tests.rs::a_one_row_title_keeps_its_centre_when_it_grows_and_when_it_wraps", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] notes = "left, centre, right and justify (word spacing per line, last line left) for existing paragraphs and added text. A centred paragraph is found either from its ragged rows or, for a one row title, from its centre sitting on the page's own centre away from the edge the rest of the text uses, so growing or wrapping it keeps that centre (#2581)" [[feature]] @@ -1974,7 +1974,7 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] +evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] notes = "line spacing (1, 1.15, 1.5, 2 × size or any multiple); space between paragraphs is not adjustable yet" [[feature]] @@ -1986,7 +1986,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/ui-egui/tests/editing.rs::the_format_panel_takes_any_scale_and_character_spacing"] +evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/ui-egui/tests/it/editing.rs::the_format_panel_takes_any_scale_and_character_spacing"] notes = "character spacing in points (-5 to 50) and horizontal scaling in percent (10 to 400) for a paragraph (Tc, Tz), rewrapped; Format text takes any value in those ranges typed, or a common one from its list" [[feature]] @@ -2010,7 +2010,7 @@ milestone = "M7" status = "shipped" commands = ["edit.text"] tools = ["page_add_text", "content_update"] -evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"] +evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"] notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box" [[feature]] @@ -2034,7 +2034,7 @@ milestone = "M7" status = "partial" commands = [] tools = ["text_lines", "text_paragraphs", "text_edit"] -evidence = ["crates/edit/src/tests.rs::hebrew_lines_read_in_typing_order", "crates/edit/src/tests.rs::hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge", "crates/edit/src/tests.rs::hebrew_letters_the_page_font_lacks_use_the_hebrew_face", "crates/edit/src/bidi.rs::hebrew_sentences_numbers_latin_and_brackets_round_trip", "crates/edit/src/tests.rs::arabic_actual_text_keeps_typing_order_when_opened_for_editing", "crates/automation/tests/automation.rs::arabic_actual_text_stays_logical_through_editor_tools_and_save_reopen", "crates/automation/tests/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen"] +evidence = ["crates/edit/src/tests.rs::hebrew_lines_read_in_typing_order", "crates/edit/src/tests.rs::hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge", "crates/edit/src/tests.rs::hebrew_letters_the_page_font_lacks_use_the_hebrew_face", "crates/edit/src/bidi.rs::hebrew_sentences_numbers_latin_and_brackets_round_trip", "crates/edit/src/tests.rs::arabic_actual_text_keeps_typing_order_when_opened_for_editing", "crates/automation/tests/it/automation.rs::arabic_actual_text_stays_logical_through_editor_tools_and_save_reopen", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen"] notes = "Added Arabic copy/search keeps typing order through per-line ActualText. Arabic/Hebrew ActualText is preserved in typing order rather than reordered twice; font-independent tests cover marks, numbers, Latin, reopening and complete replacement. This does not add Arabic fallback fonts or bidi caret/selection. Hebrew: lines and paragraphs read in typing order and are written back in drawing order, right-aligned by default; letters the page's font lacks use the craft-fonts Noto Sans Hebrew face as Type 3 glyphs. Japanese uses its Type 3 fallback. Missing: Hebrew points (niqqud) in replacement text, writing Arabic (shaping), right-to-left typing in the edit box (misc.rtl-ui), lines a producer draws word by word from the right, vertical CJK. Also (#1116): bounded single-face existing-text engine coverage now exists (Arabic marks/ligatures/mixed bidi and explicit full-face Chinese subset completion), with private keyboard/save/reopen checks. General mixed-font/style contextual runs, bidi caret/selection, legacy programs and release font availability remain incomplete, so broader feature remains planned" [[feature]] @@ -2070,7 +2070,7 @@ milestone = "M7" status = "shipped" commands = ["edit.image"] tools = ["page_add_image"] -evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::a_picture_on_the_clipboard_is_pasted_onto_the_page", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"] +evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::a_picture_on_the_clipboard_is_pasted_onto_the_page", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"] notes = "PNG, JPEG, TIFF, GIF, BMP; centred at natural size or in a rect. ⌘V / Ctrl+V pastes a picture from the system clipboard onto the current page the same way (desktop only)" [[feature]] @@ -2082,7 +2082,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text", "edit.image"] tools = ["image_edit"] -evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools"] +evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools"] notes = "existing page images (right-click ▸ Replace Image) and added images: the new picture is drawn in the same place" [[feature]] @@ -2094,7 +2094,7 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["image_edit"] -evidence = ["crates/edit/src/tests.rs::an_image_turns_by_any_angle_about_its_centre_and_saves_the_matrix", "crates/ui-egui/tests/editing.rs::dragging_the_rotate_knob_turns_a_page_image", "crates/automation/tests/automation.rs::editing_page_images_through_tools"] +evidence = ["crates/edit/src/tests.rs::an_image_turns_by_any_angle_about_its_centre_and_saves_the_matrix", "crates/ui-egui/tests/it/editing.rs::dragging_the_rotate_knob_turns_a_page_image", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools"] notes = "drag the knob above a selected image (Shift: 15 degree steps) or image_edit action rotate_by with degrees; the angle is clockwise about the visible centre and is written as one extra cm (undoable). Missing: no numeric angle field, no live pixel preview while turning (an outline is drawn), the selection frame and corner handles of a turned image are its axis-aligned bounds, corner handles of an already turned image scale its axis-aligned bounds (not tested), no angle shown for an already turned image" [[feature]] @@ -2106,7 +2106,7 @@ milestone = "M7" status = "shipped" commands = ["edit.image"] tools = ["content_update", "image_edit"] -evidence = ["crates/ui-egui/tests/editing.rs::cropping_a_picture_with_its_handles", "crates/engine/src/tests.rs::cropping_an_existing_image_clips_it_and_moves_with_it", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/automation.rs::adding_content_through_tools"] +evidence = ["crates/ui-egui/tests/it/editing.rs::cropping_a_picture_with_its_handles", "crates/engine/src/tests.rs::cropping_an_existing_image_clips_it_and_moves_with_it", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/it/automation.rs::adding_content_through_tools"] notes = "Existing page images: right-click ▸ Crop Image puts the picture in crop mode: its eight handles trim (the inside pans), the part that will go is dimmed, Enter or Done applies, Esc or Cancel leaves it; Remove Crop shows the whole picture again. The pixels stay where they are (a clipping path inside the picture's own scope, so a later move or resize carries it along; cropping again replaces the earlier crop, so it can grow back); image_edit crop. Added images: trim each side by percent in Edit image, clipped. Not yet: cropping grouped Form artwork by handles is allowed the same way (it is a clip), but a shared clip of the original file is not changed." [[feature]] @@ -2118,7 +2118,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["image_edit", "content_update"] -evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/automation/tests/automation.rs::large_added_image_rotations_keep_orientation_and_undo", "crates/automation/tests/automation.rs::large_added_image_rotations_round_trip_through_mcp"] +evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/automation/tests/it/automation.rs::large_added_image_rotations_keep_orientation_and_undo", "crates/automation/tests/it/automation.rs::large_added_image_rotations_round_trip_through_mcp"] notes = "Existing raster images and grouped Form artwork: rotate either way about the centre, flip horizontally or vertically (a q/cm/Q wrapper); Forms stay intact as groups; added images too. Added-image content_update rotation accepts positive or negative integer multiples of 90 degrees; reducing the angle before addition prevents overflow at signed 64-bit limits and preserves the current orientation through full turns, Undo/Redo and save/reopen. Added-image rotations remain limited to 90-degree steps." [[feature]] @@ -2130,7 +2130,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["image_edit", "image_hit"] -evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools"] +evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools"] notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams; shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" [[feature]] @@ -2142,7 +2142,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["image_save"] -evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] +evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] @@ -2202,7 +2202,7 @@ milestone = "M11" status = "partial" commands = ["edit.edit_text"] tools = ["object_list", "object_move"] -evidence = ["crates/ui-egui/tests/object_selection.rs::marquee_and_mixed_group_drag_move_once_and_undo_together", "crates/automation/tests/automation.rs::mixed_objects_move_through_tools_with_generation_and_one_undo", "crates/edit/src/tests.rs::group_moves_preserve_glyph_bytes_and_unselected_text_state", "crates/engine/src/tests.rs::mixed_object_moves_are_one_undo_step_and_survive_save_reopen"] +evidence = ["crates/ui-egui/tests/it/object_selection.rs::marquee_and_mixed_group_drag_move_once_and_undo_together", "crates/automation/tests/it/automation.rs::mixed_objects_move_through_tools_with_generation_and_one_undo", "crates/edit/src/tests.rs::group_moves_preserve_glyph_bytes_and_unselected_text_state", "crates/engine/src/tests.rs::mixed_object_moves_are_one_undo_step_and_survive_save_reopen"] notes = "Same-page mixed selection (#844): Shift/Ctrl/Command-click toggles, empty-space marquee (additive with modifiers), drag previews and one atomic undo step for existing paragraphs, Image/Form XObjects and added text/images. Source glyph bytes, fonts and kerning retained; unselected text and shared Form resources stay put. Generation-scoped references with optional automation guard; count and geometry bounded; unsupported vertical/clipping text refused without partial changes. Still missing: arbitrary paths, inline images, cross-page movement, group resizing, arrange/align and a general object editor. See docs/editing-multiple-objects.md." [[feature]] @@ -2226,7 +2226,7 @@ milestone = "M7" status = "shipped" commands = ["edit.link"] tools = ["link_add"] -evidence = ["crates/ui-egui/tests/ui_group/pdf_links.rs::drawing_editing_and_deleting_links", "crates/automation/tests/automation.rs::links_through_tools", "crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed"] +evidence = ["crates/ui-egui/tests/it/pdf_links.rs::drawing_editing_and_deleting_links", "crates/automation/tests/it/automation.rs::links_through_tools", "crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed"] notes = "drag a rectangle; go to a page or open a web page" [[feature]] @@ -2238,7 +2238,7 @@ milestone = "M7" status = "shipped" commands = ["edit.link"] tools = ["link_edit"] -evidence = ["crates/ui-egui/tests/ui_group/pdf_links.rs::drawing_editing_and_deleting_links", "crates/automation/tests/automation.rs::links_through_tools", "crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed"] +evidence = ["crates/ui-egui/tests/it/pdf_links.rs::drawing_editing_and_deleting_links", "crates/automation/tests/it/automation.rs::links_through_tools", "crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed"] notes = "visible/invisible rectangle, highlight, thickness, solid/dashed/underline and colour; supported page/URI action editing. Appearance-only edits preserve unsupported actions, including GoToR." [[feature]] @@ -2250,7 +2250,7 @@ milestone = "M7" status = "shipped" commands = ["edit.links_from_urls"] tools = ["links_from_urls"] -evidence = ["crates/engine/src/tests.rs::links_from_urls_and_link_edits", "crates/automation/tests/automation.rs::links_through_tools", "crates/annot/src/tests.rs::urls_are_found_in_text"] +evidence = ["crates/engine/src/tests.rs::links_from_urls_and_link_edits", "crates/automation/tests/it/automation.rs::links_through_tools", "crates/annot/src/tests.rs::urls_are_found_in_text"] notes = "http(s):// and www. addresses; already-linked text skipped" [[feature]] @@ -2264,10 +2264,10 @@ commands = ["edit.remove_links"] tools = ["links_remove"] evidence = [ "crates/engine/src/tests.rs::links_from_urls_and_link_edits", - "crates/automation/tests/automation.rs::links_through_tools", + "crates/automation/tests/it/automation.rs::links_through_tools", "crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", - "crates/ui-egui/tests/ui_group/pdf_links.rs::removing_all_links_and_undo", - "crates/ui-egui/tests/ui_group/pdf_links.rs::the_link_context_menu_shows_only_for_a_right_click_on_a_link", + "crates/ui-egui/tests/it/pdf_links.rs::removing_all_links_and_undo", + "crates/ui-egui/tests/it/pdf_links.rs::the_link_context_menu_shows_only_for_a_right_click_on_a_link", ] notes = "removes all link annotations across the document or on specified pages (links_remove pages); a right-click on a link offers Properties, Delete and Remove all links; undoable, with a toast giving the count" @@ -2280,7 +2280,7 @@ milestone = "M7" status = "partial" commands = ["edit.link"] tools = ["link_add"] -evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/ui-egui/tests/ui_group/pdf_links.rs::drawing_editing_and_deleting_links"] +evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/ui-egui/tests/it/pdf_links.rs::drawing_editing_and_deleting_links"] notes = "go to a page (Fit view); named destinations and zoom pending" [[feature]] @@ -2292,7 +2292,7 @@ milestone = "M7" status = "shipped" commands = ["edit.link"] tools = ["link_add"] -evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/automation/tests/automation.rs::links_through_tools", "crates/ui-egui/tests/document_links.rs::a_link_asks_first_and_opens_when_allowed", "crates/ui-egui/tests/document_links.rs::a_button_uri_action_asks_first", "crates/ui-egui/tests/document_links.rs::an_email_link_cannot_attach_a_local_file", "crates/ui-egui/tests/document_links.rs::a_lookalike_host_is_shown_in_punycode_and_flagged"] +evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/automation/tests/it/automation.rs::links_through_tools", "crates/ui-egui/tests/it/document_links.rs::a_link_asks_first_and_opens_when_allowed", "crates/ui-egui/tests/it/document_links.rs::a_button_uri_action_asks_first", "crates/ui-egui/tests/it/document_links.rs::an_email_link_cannot_attach_a_local_file", "crates/ui-egui/tests/it/document_links.rs::a_lookalike_host_is_shown_in_punycode_and_flagged"] notes = "Opening a document's link or button URI asks first; only http, https and mailto addresses are offered (#90, #91), and an email address that asks the email app to attach or insert a local file is refused. The prompt shows the host as the browser receives it: international hosts in punycode, lookalikes that mix alphabets flagged, numeric hosts in their usual form." [[feature]] @@ -2328,7 +2328,7 @@ milestone = "M12" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui_group/layer_actions.rs::links_set_layer_visibility", "crates/ui-egui/tests/ui_group/layer_actions.rs::push_buttons_set_layer_visibility", "crates/engine/src/tests.rs::set_layer_actions_change_what_renders", "crates/engine/src/tests.rs::set_layer_actions_toggle_in_order_and_keep_radio_groups", "crates/render/src/inspect.rs::links_read_set_layer_actions", "crates/forms/src/tests.rs::push_buttons_read_set_layer_actions"] +evidence = ["crates/ui-egui/tests/it/layer_actions.rs::links_set_layer_visibility", "crates/ui-egui/tests/it/layer_actions.rs::push_buttons_set_layer_visibility", "crates/engine/src/tests.rs::set_layer_actions_change_what_renders", "crates/engine/src/tests.rs::set_layer_actions_toggle_in_order_and_keep_radio_groups", "crates/render/src/inspect.rs::links_read_set_layer_actions", "crates/forms/src/tests.rs::push_buttons_read_set_layer_actions"] notes = "Links and push buttons run it: ON, OFF and Toggle in order, by optional content group, and with /PreserveRB (the default) a layer turned on turns off the rest of its /RBGroups group. Like the Layers panel it changes the view, not the document (no undo step, not saved). Not yet: creating the action (Link Properties, a field's Actions), bookmarks and other triggers, /Next chains, JavaScript ocg.state, and the Layers panel keeping radio-button groups itself." [[feature]] @@ -2340,7 +2340,7 @@ milestone = "M6" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/buttons.rs::hide_actions_hide_and_show_fields", "crates/forms/src/tests.rs::push_buttons_read_hide_actions"] +evidence = ["crates/ui-egui/tests/it/buttons.rs::hide_actions_hide_and_show_fields", "crates/forms/src/tests.rs::push_buttons_read_hide_actions"] notes = "A push button's Hide action (/T names, field or widget references, arrays; /H) hides or shows the fields and their kids; hidden widgets take no clicks, tab stops or highlights. A widget reference acts on its whole field, and only form fields are hidden, not other annotations." [[feature]] @@ -2400,7 +2400,7 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer"] tools = ["doc_header_footer"] -evidence = ["crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"] +evidence = ["crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"] notes = "Standard faces only: Helvetica, Times or Courier (#3754; no bold, italic or embedded fonts yet); drawn in display space on rotated pages; artifact-tagged" [[feature]] @@ -2412,7 +2412,7 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer.update", "edit.header_footer.remove"] tools = ["doc_header_footer", "doc_remove_marks"] -evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"] +evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"] notes = "recognises PdfCraft marks; Acrobat-made headers are not detected yet" [[feature]] @@ -2436,7 +2436,7 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer"] tools = ["doc_header_footer"] -evidence = ["crates/ui-egui/src/marks_ui.rs::page_ranges_with_subsets", "crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"] +evidence = ["crates/ui-egui/src/marks_ui.rs::page_ranges_with_subsets", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"] notes = "all / from–to, all / even / odd" [[feature]] @@ -2448,7 +2448,7 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer"] tools = ["doc_header_footer"] -evidence = ["crates/edit/src/tests.rs::shrink_scales_the_original_content_into_the_band_between_header_and_footer", "crates/edit/src/tests.rs::shrink_follows_the_display_space_of_a_rotated_page", "crates/edit/src/tests.rs::shrink_is_undone_by_remove_and_not_doubled_by_update", "crates/automation/tests/automation.rs::a_shrinking_header_and_footer_moves_the_page_content_out_of_their_way_through_tools", "crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_can_shrink_the_page_content"] +evidence = ["crates/edit/src/tests.rs::shrink_scales_the_original_content_into_the_band_between_header_and_footer", "crates/edit/src/tests.rs::shrink_follows_the_display_space_of_a_rotated_page", "crates/edit/src/tests.rs::shrink_is_undone_by_remove_and_not_doubled_by_update", "crates/automation/tests/it/automation.rs::a_shrinking_header_and_footer_moves_the_page_content_out_of_their_way_through_tools", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_can_shrink_the_page_content"] notes = "#2140: the page's original content is scaled evenly into the band the header and footer leave free (margin, tallest box, half the font size of clearance), centred across, in display space; Remove and Update undo it. Not moved yet: annotations (links, comments, form fields) and PdfCraft's own added text and images; not compared side by side with Acrobat's exact scale" [[feature]] @@ -2460,7 +2460,7 @@ milestone = "M7" status = "shipped" commands = ["edit.watermark"] tools = ["doc_watermark"] -evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/ui_group/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/automation.rs::headers_watermarks_and_backgrounds_through_tools"] +evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/it/automation.rs::headers_watermarks_and_backgrounds_through_tools"] notes = "Helvetica; fit-to-page or a size" [[feature]] @@ -2496,7 +2496,7 @@ milestone = "M7" status = "partial" commands = ["edit.watermark"] tools = ["doc_watermark"] -evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/edit/src/tests.rs::watermark_offset_moves_the_centre_right_and_up", "crates/edit/src/tests.rs::a_watermark_offset_that_is_not_finite_is_refused", "crates/automation/tests/automation.rs::watermarks_move_from_the_page_centre_through_tools", "crates/ui-egui/src/marks_ui.rs::the_watermark_edit_carries_the_offset"] +evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/edit/src/tests.rs::watermark_offset_moves_the_centre_right_and_up", "crates/edit/src/tests.rs::a_watermark_offset_that_is_not_finite_is_refused", "crates/automation/tests/it/automation.rs::watermarks_move_from_the_page_centre_through_tools", "crates/ui-egui/src/marks_ui.rs::the_watermark_edit_carries_the_offset"] notes = "rotation, opacity, behind/on top, and a horizontal/vertical offset from the page centre in points (dialog and offset_x/offset_y); no offset from the page edge or in page units, and no print/screen-only variants yet" [[feature]] @@ -2508,7 +2508,7 @@ milestone = "M7" status = "shipped" commands = ["edit.background"] tools = ["doc_background"] -evidence = ["crates/ui-egui/tests/ui_group/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/ui_group/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright"] +evidence = ["crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/it/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright"] notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page" [[feature]] @@ -2604,7 +2604,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/ui-egui/tests/content.rs::format_text_panel_sets_alignment_and_lists_planned_controls_disabled", "crates/ui-egui/tests/content.rs::format_text_rows_line_up_and_fit_the_panel"] +evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/ui-egui/tests/it/content.rs::format_text_panel_sets_alignment_and_lists_planned_controls_disabled", "crates/ui-egui/tests/it/content.rs::format_text_rows_line_up_and_fit_the_panel"] notes = "Edit a PDF ▸ Format text, laid out as Acrobat's panel, for the paragraph being edited and for added text: font (the source font kept, the document's fonts, families, installed or loaded font files), size, colour, alignment, bold and italic, line spacing, horizontal scale and character spacing; each change applies at once as one undo step, keeping the paragraph open. Shown disabled with a Planned tooltip until they exist: bulleted and numbered lists (edit.lists), superscript and subscript (edit.text-super-subscript), paragraph spacing, and underline on added text." [[feature]] @@ -2632,7 +2632,7 @@ milestone = "M4" status = "shipped" commands = ["page.organize"] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/editing.rs::organize_opens_on_the_current_page", "crates/ui-egui/tests/control.rs::organize_overview_is_reachable_through_the_control_channel", "crates/ui-egui/tests/editing.rs::organize_panel_comes_with_the_grid_and_closes_it", "crates/ui-egui/tests/editing.rs::leaving_organize_goes_back_to_the_workspace_panel"] +evidence = ["crates/ui-egui/tests/it/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/it/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/it/editing.rs::organize_opens_on_the_current_page", "crates/ui-egui/tests/it/control.rs::organize_overview_is_reachable_through_the_control_channel", "crates/ui-egui/tests/it/editing.rs::organize_panel_comes_with_the_grid_and_closes_it", "crates/ui-egui/tests/it/editing.rs::leaving_organize_goes_back_to_the_workspace_panel"] notes = "Opening the Organize pages tool enters the overview directly, on the current page: selected and scrolled into view (#1910). Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection. The left panel is laid out as Acrobat's Organize pages panel: Pages selected (typed ranges, or All, Even, Odd, Landscape or Portrait pages; empty, it shows the current page), rotate, delete, extract, Insert (a blank page, pages from a file, duplicates), Replace, Split, Bates numbering, Set page boxes, Crop pages, Print pages and Page labels. Closing it, by its Close, the grid's Close or opening a page, returns to the workspace's own panel (Edit, Convert, E-Sign or All tools). Page transitions, Page templates and Page properties are listed but planned." [[feature]] @@ -2644,7 +2644,7 @@ milestone = "M4" status = "shipped" commands = ["page.organize"] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_select_all_shortcut_preserves_current_page_and_sets_range_anchor", "crates/ui-egui/tests/control.rs::select_all_key_selects_every_page_in_organize", "crates/ui-egui/tests/editing.rs::organize_page_range_tracks_thumbnails_and_applies_typed_selection", "crates/ui-egui/tests/editing.rs::organize_page_range_rejects_invalid_input_and_escape_cancels_without_clearing", "crates/ui-egui/tests/control.rs::state_reports_the_selected_pages"] +evidence = ["crates/ui-egui/tests/it/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/it/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/it/editing.rs::organize_select_all_shortcut_preserves_current_page_and_sets_range_anchor", "crates/ui-egui/tests/it/control.rs::select_all_key_selects_every_page_in_organize", "crates/ui-egui/tests/it/editing.rs::organize_page_range_tracks_thumbnails_and_applies_typed_selection", "crates/ui-egui/tests/it/editing.rs::organize_page_range_rejects_invalid_input_and_escape_cancels_without_clearing", "crates/ui-egui/tests/it/control.rs::state_reports_the_selected_pages"] notes = "Ctrl+A / ⌘A selects every page in the Organize grid, preserving the current page; text inputs and dialogs keep their own keyboard input. Page checkboxes also toggle selection without modifiers (organize_checkboxes_toggle_multiple_pages_without_modifiers). An editable physical page-range list stays synchronized with thumbnails; Enter, focus loss or Apply commits valid input, Escape cancels, and invalid input preserves the selection. UI control select accepts the same bounded ranges." [[feature]] @@ -2656,7 +2656,7 @@ milestone = "M4" status = "shipped" commands = ["page.rotate", "page.rotate_ccw"] tools = ["page_rotate"] -evidence = ["crates/organize/src/tests.rs::rotate_repeated_indices_accumulate_from_the_current_page_value", "crates/organize/src/tests.rs::rotate_uses_inherited_rotation_and_round_trips", "crates/ui-egui/tests/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/editing.rs::organize_page_actions_rotate_only_the_named_page_and_undo", "crates/ui-egui/tests/editing.rs::organize_local_actions_respect_document_permissions", "crates/organize/src/tests.rs::indirect_page_rotation_is_read_and_updated_from_its_effective_value", "crates/organize/src/tests.rs::malformed_indirect_rotations_keep_the_zero_fallback"] +evidence = ["crates/organize/src/tests.rs::rotate_repeated_indices_accumulate_from_the_current_page_value", "crates/organize/src/tests.rs::rotate_uses_inherited_rotation_and_round_trips", "crates/ui-egui/tests/it/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/it/editing.rs::organize_page_actions_rotate_only_the_named_page_and_undo", "crates/ui-egui/tests/it/editing.rs::organize_local_actions_respect_document_permissions", "crates/organize/src/tests.rs::indirect_page_rotation_is_read_and_updated_from_its_effective_value", "crates/organize/src/tests.rs::malformed_indirect_rotations_keep_the_zero_fallback"] notes = "Rotation queries and edits resolve direct and inherited indirect integer values; full and incremental save regressions preserve sibling rotations. Existing quarter-turn restrictions and malformed-value zero fallback remain." [[feature]] @@ -2668,7 +2668,7 @@ milestone = "M4" status = "shipped" commands = ["page.rotate_dialog"] tools = ["page_rotate"] -evidence = ["crates/ui-egui/tests/editing.rs::extract_options_and_rotate_pages_dialog", "crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/engine/src/tests.rs::split_by_size_and_bookmarks_and_page_filters"] +evidence = ["crates/ui-egui/tests/it/editing.rs::extract_options_and_rotate_pages_dialog", "crates/automation/tests/it/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/engine/src/tests.rs::split_by_size_and_bookmarks_and_page_filters"] notes = "Rotate Pages: all/selection/range; even/odd; landscape/portrait" [[feature]] @@ -2680,7 +2680,7 @@ milestone = "M4" status = "shipped" commands = ["page.delete"] tools = ["page_delete"] -evidence = ["crates/organize/src/tests.rs::delete_keeps_inherited_attributes_on_survivors", "crates/organize/src/tests.rs::cannot_delete_every_page_or_missing_pages", "crates/ui-egui/tests/editing.rs::delete_is_refused_when_it_would_remove_every_page", "crates/organize/src/tests.rs::deleting_a_page_keeps_sixty_thousand_named_destinations_usable", "crates/automation/tests/automation.rs::page_delete_handles_deep_destination_trees_with_history_and_reopen", "crates/automation/tests/automation.rs::page_delete_handles_sixty_thousand_destinations_with_history_and_reopen"] +evidence = ["crates/organize/src/tests.rs::delete_keeps_inherited_attributes_on_survivors", "crates/organize/src/tests.rs::cannot_delete_every_page_or_missing_pages", "crates/ui-egui/tests/it/editing.rs::delete_is_refused_when_it_would_remove_every_page", "crates/organize/src/tests.rs::deleting_a_page_keeps_sixty_thousand_named_destinations_usable", "crates/automation/tests/it/automation.rs::page_delete_handles_deep_destination_trees_with_history_and_reopen", "crates/automation/tests/it/automation.rs::page_delete_handles_sixty_thousand_destinations_with_history_and_reopen"] notes = "Named-destination pruning is iterative and visits shared/cyclic indirect nodes once, without a name-count work limit. Deep trees and 60,000 named destinations retain surviving targets through delete, undo/redo and full/incremental save/reopen. No new destination-tree repair is attempted." [[feature]] @@ -2692,7 +2692,7 @@ milestone = "M4" status = "shipped" commands = [] tools = ["page_move"] -evidence = ["crates/organize/src/tests.rs::move_pages_reorders", "crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it"] +evidence = ["crates/organize/src/tests.rs::move_pages_reorders", "crates/ui-egui/tests/it/editing.rs::shift_click_selects_a_range_and_moves_it"] notes = "" [[feature]] @@ -2704,7 +2704,7 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::dragging_thumbnails_reorders_pages"] +evidence = ["crates/ui-egui/tests/it/editing.rs::dragging_thumbnails_reorders_pages"] notes = "drag thumbnails (or the selection) in the page grid; an insertion bar shows where they go; move earlier/later buttons too" [[feature]] @@ -2716,7 +2716,7 @@ milestone = "M4" status = "shipped" commands = ["page.insert_blank"] tools = ["page_insert_blank"] -evidence = ["crates/organize/src/tests.rs::inserts_blank_page", "crates/ui-egui/tests/editing.rs::insert_blank_page_after_selection", "crates/engine/src/tests.rs::insert_blank_between_pages_keeps_both_neighbours"] +evidence = ["crates/organize/src/tests.rs::inserts_blank_page", "crates/ui-egui/tests/it/editing.rs::insert_blank_page_after_selection", "crates/engine/src/tests.rs::insert_blank_between_pages_keeps_both_neighbours"] notes = "" [[feature]] @@ -2728,7 +2728,7 @@ milestone = "M4" status = "shipped" commands = ["page.insert"] tools = ["page_insert_file"] -evidence = ["crates/organize/src/tests.rs::inserting_pages_from_another_file_is_an_incremental_edit", "crates/organize/src/tests.rs::inserting_a_file_brings_its_bookmarks_under_one_named_after_it", "crates/automation/tests/automation.rs::inserting_a_file_brings_its_bookmarks", "crates/ui-egui/tests/editing.rs::inserting_a_file_goes_after_the_selection_and_undoes", "crates/ui-egui/tests/editing.rs::plus_between_pages_inserts_picked_files_there_in_order"] +evidence = ["crates/organize/src/tests.rs::inserting_pages_from_another_file_is_an_incremental_edit", "crates/organize/src/tests.rs::inserting_a_file_brings_its_bookmarks_under_one_named_after_it", "crates/automation/tests/it/automation.rs::inserting_a_file_brings_its_bookmarks", "crates/ui-egui/tests/it/editing.rs::inserting_a_file_goes_after_the_selection_and_undoes", "crates/ui-egui/tests/it/editing.rs::plus_between_pages_inserts_picked_files_there_in_order"] notes = "After the selection (toolbar, File menu) or at any gap of the page grid (a + between pages and at both ends); several files at once, in order; images and .txt files are converted first; the inserted pages end up selected. The file's bookmarks come along, collapsed under one named after the file, placed among the document's bookmarks where the pages went (as Combine does per file). The page_insert_file tool takes PDFs only. Missing: choosing which pages of the file to insert in the app; the file's tags (structure tree) are not merged." [[feature]] @@ -2776,7 +2776,7 @@ milestone = "M4" status = "partial" commands = ["page.organize"] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::files_dropped_between_pages_are_inserted_there", "crates/ui-egui/tests/editing.rs::files_dropped_before_the_pointer_is_known_wait_for_it", "crates/ui-egui/tests/editing.rs::files_dropped_outside_the_page_grid_still_open_as_documents"] +evidence = ["crates/ui-egui/tests/it/editing.rs::files_dropped_between_pages_are_inserted_there", "crates/ui-egui/tests/it/editing.rs::files_dropped_before_the_pointer_is_known_wait_for_it", "crates/ui-egui/tests/it/editing.rs::files_dropped_outside_the_page_grid_still_open_as_documents"] notes = "Desktop: PDFs, images and .txt files dropped on the Organize page grid are inserted at the gap under the pointer, in order, and selected (elsewhere a dropped file still opens as a document). While the files hover, a bar at the gap and a + badge follow the pointer; on macOS the pointer is read from AppKit, because the windowing layer sends no moves during a file drag. Missing: on Windows and Linux there is no live position, so the grid is only outlined and the files go to the gap the pointer is at right after the drop (or the end after a second); the web build (dropped files open as documents). Covered by simulated drop events; a real drag from Finder is not in the automated tests." [[feature]] @@ -2788,7 +2788,7 @@ milestone = "M4" status = "shipped" commands = ["page.extract"] tools = ["page_extract"] -evidence = ["crates/ui-egui/tests/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"] +evidence = ["crates/ui-egui/tests/it/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"] notes = "Carries the PDF/X output intent and identification (#263) in a new XMP packet; the source XMP (dates, other schemas) is not copied." [[feature]] @@ -2800,7 +2800,7 @@ milestone = "M4" status = "shipped" commands = ["page.extract"] tools = ["page_extract"] -evidence = ["crates/ui-egui/tests/editing.rs::extract_options_and_rotate_pages_dialog", "crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/ui-egui/tests/pickers.rs::extract_pages_saves_under_the_file_name_typed_in_the_dialog"] +evidence = ["crates/ui-egui/tests/it/editing.rs::extract_options_and_rotate_pages_dialog", "crates/automation/tests/it/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/ui-egui/tests/it/pickers.rs::extract_pages_saves_under_the_file_name_typed_in_the_dialog"] notes = "The dialog has a File name field (#737), filled in with the document's name: pages are written as ` (page N).pdf` into the chosen folder, and one renamed page as `.pdf`. Extracting into a new tab names the tab (and so Save As) after it." [[feature]] @@ -2812,7 +2812,7 @@ milestone = "M4" status = "shipped" commands = ["page.extract"] tools = ["page_extract"] -evidence = ["crates/ui-egui/tests/editing.rs::extract_options_and_rotate_pages_dialog", "crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options"] +evidence = ["crates/ui-egui/tests/it/editing.rs::extract_options_and_rotate_pages_dialog", "crates/automation/tests/it/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options"] notes = "undoable" [[feature]] @@ -2824,7 +2824,7 @@ milestone = "M4" status = "shipped" commands = ["page.replace"] tools = ["page_replace"] -evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools", "crates/ui-egui/src/canvas.rs::replacing_a_page_after_layout_never_uploads_an_oversized_texture", "crates/ui-egui/src/canvas.rs::oversized_raster_results_do_not_replace_cached_textures"] +evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/it/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/it/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/it/automation.rs::replacing_pages_through_tools", "crates/ui-egui/src/canvas.rs::replacing_a_page_after_layout_never_uploads_an_oversized_texture", "crates/ui-egui/src/canvas.rs::oversized_raster_results_do_not_replace_cached_textures"] notes = "Content, resources and boxes replaced; links, comments, fields and bookmarks kept. Page-list edits discard demand laid out against the previous geometry; the next frame plans bounded backdrops and tiles. Raster uploads reject empty images and dimensions beyond the current GPU texture limit before replacing cached textures (#3149). This covers the viewer's page, tile, thumbnail, organize and print-preview result paths; it does not change the renderer's output resolution or add a global GPU memory budget." [[feature]] @@ -2836,7 +2836,7 @@ milestone = "M4" status = "shipped" commands = ["page.duplicate"] tools = ["page_duplicate"] -evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::duplicate_pages_copies_the_current_page", "crates/ui-egui/tests/editing.rs::dragging_with_the_copy_modifier_copies_the_pages_into_the_gap", "crates/ui-egui/tests/control.rs::ctrl_dragging_a_thumbnail_through_the_control_channel_copies_the_page", "crates/organize/src/tests.rs::duplicate_pages_inserts_copies_after_the_last", "crates/organize/src/tests.rs::duplicate_pages_at_inserts_the_copies_there", "crates/automation/tests/automation.rs::page_duplicate_at_inserts_the_copies_there"] +evidence = ["crates/ui-egui/tests/it/pageboxes.rs::duplicate_pages_copies_the_current_page", "crates/ui-egui/tests/it/editing.rs::dragging_with_the_copy_modifier_copies_the_pages_into_the_gap", "crates/ui-egui/tests/it/control.rs::ctrl_dragging_a_thumbnail_through_the_control_channel_copies_the_page", "crates/organize/src/tests.rs::duplicate_pages_inserts_copies_after_the_last", "crates/organize/src/tests.rs::duplicate_pages_at_inserts_the_copies_there", "crates/automation/tests/it/automation.rs::page_duplicate_at_inserts_the_copies_there"] notes = "copies share fonts and images with the originals; Ctrl-drag (Option on macOS) in the page grid drops the copies at the gap instead of moving the pages, with a \"+\" badge and the copies selected (#2997); page_duplicate takes `at` for where the first copy goes (left out: after the last of them)" [[feature]] @@ -2848,7 +2848,7 @@ milestone = "M4" status = "shipped" commands = ["page.reverse"] tools = ["page_reverse"] -evidence = ["crates/organize/src/tests.rs::reverse_pages_reorders", "crates/organize/src/tests.rs::cannot_reverse_missing_or_fewer_than_two_pages", "crates/automation/tests/automation.rs::reversing_pages_through_tools", "crates/ui-egui/tests/editing.rs::reverse_pages_reverses_the_selection_or_else_the_whole_document"] +evidence = ["crates/organize/src/tests.rs::reverse_pages_reorders", "crates/organize/src/tests.rs::cannot_reverse_missing_or_fewer_than_two_pages", "crates/automation/tests/it/automation.rs::reversing_pages_through_tools", "crates/ui-egui/tests/it/editing.rs::reverse_pages_reverses_the_selection_or_else_the_whole_document"] notes = "Organize Pages ▸ More ▸ Reverse pages, or the command palette: two or more selected pages swap among their own positions, otherwise the whole document reverses. One undo step. Not yet: an entry in the page thumbnails' right-click menu" [[feature]] @@ -2860,7 +2860,7 @@ milestone = "M4" status = "shipped" commands = ["page.copy", "page.cut", "page.paste"] tools = ["page_insert_file"] -evidence = ["crates/ui-egui/tests/editing.rs::copying_cutting_and_pasting_pages"] +evidence = ["crates/ui-egui/tests/it/editing.rs::copying_cutting_and_pasting_pages"] notes = "⌘C/⌘X/⌘V and the thumbnail menu in Organize Pages; paste works across documents" [[feature]] @@ -2896,7 +2896,7 @@ milestone = "M4" status = "shipped" commands = ["page.organize"] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::page_grid_zooms_with_its_buttons_keys_and_pinch", "crates/ui-egui/tests/editing.rs::a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap", "crates/ui-egui/tests/editing.rs::zoomed_pages_in_view_are_rendered_at_the_size_drawn", "crates/ui-egui/tests/editing.rs::page_grid_size_is_remembered_for_every_document_and_restart"] +evidence = ["crates/ui-egui/tests/it/editing.rs::page_grid_zooms_with_its_buttons_keys_and_pinch", "crates/ui-egui/tests/it/editing.rs::a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap", "crates/ui-egui/tests/it/editing.rs::zoomed_pages_in_view_are_rendered_at_the_size_drawn", "crates/ui-egui/tests/it/editing.rs::page_grid_size_is_remembered_for_every_document_and_restart"] notes = "Organize page grid: 50–300% with toolbar buttons (and a percentage that resets to 100%), trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0; pages drawn larger than their thumbnails are rendered at the size drawn while they are in view (the thumbnail stands in until then, and after an edit), so text stays sharp however long the document is. As in Acrobat, the size last chosen is one for every document and kept in the settings across restarts (also set by --grid-zoom). Missing in Organize: a slider as in Acrobat. The Pages inspector has its own 50–150% slider and buttons (#3808)." [[feature]] @@ -2908,7 +2908,7 @@ milestone = "M4" status = "shipped" commands = ["page.split"] tools = ["doc_split"] -evidence = ["crates/ui-egui/tests/editing.rs::split_dialog_writes_one_file_per_part", "crates/organize/src/tests.rs::split_ranges_cover_every_page_once", "crates/organize/src/tests.rs::split_produces_standalone_documents_with_metadata", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"] +evidence = ["crates/ui-egui/tests/it/editing.rs::split_dialog_writes_one_file_per_part", "crates/organize/src/tests.rs::split_ranges_cover_every_page_once", "crates/organize/src/tests.rs::split_produces_standalone_documents_with_metadata", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"] notes = "Each part carries the PDF/X output intent and identification (#263), as Extract does." [[feature]] @@ -2920,7 +2920,7 @@ milestone = "M4" status = "shipped" commands = ["page.split"] tools = ["doc_split"] -evidence = ["crates/ui-egui/tests/editing.rs::split_before_selected_pages"] +evidence = ["crates/ui-egui/tests/it/editing.rs::split_before_selected_pages"] notes = "" [[feature]] @@ -2932,7 +2932,7 @@ milestone = "M4" status = "shipped" commands = ["page.split"] tools = ["doc_split"] -evidence = ["crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/engine/src/tests.rs::split_by_size_and_bookmarks_and_page_filters"] +evidence = ["crates/automation/tests/it/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/engine/src/tests.rs::split_by_size_and_bookmarks_and_page_filters"] notes = "greedy by single-page size (an over-estimate: parts stay under the limit)" [[feature]] @@ -2944,7 +2944,7 @@ milestone = "M4" status = "shipped" commands = ["page.split"] tools = ["doc_split"] -evidence = ["crates/automation/tests/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/engine/src/tests.rs::split_by_size_and_bookmarks_and_page_filters"] +evidence = ["crates/automation/tests/it/automation.rs::organizing_with_filters_bookmark_splits_and_extract_options", "crates/engine/src/tests.rs::split_by_size_and_bookmarks_and_page_filters"] notes = "files named after the bookmarks" [[feature]] @@ -2980,7 +2980,7 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/editing.rs::a_folder_adds_its_pdfs_images_and_text_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/editing.rs::images_and_text_files_join_the_combine_list_as_pdfs", "crates/ui-egui/tests/editing.rs::files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees", "crates/ui-egui/tests/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/automation/tests/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/automation/tests/automation.rs::combine_takes_images_and_text_files", "crates/engine/src/tests.rs::files_for_combine_are_made_into_pdfs_and_pdfs_pass_through", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"] +evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/it/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/it/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/it/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/it/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/it/editing.rs::a_folder_adds_its_pdfs_images_and_text_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/it/editing.rs::images_and_text_files_join_the_combine_list_as_pdfs", "crates/ui-egui/tests/it/editing.rs::files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees", "crates/ui-egui/tests/it/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/automation/tests/it/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/automation/tests/it/automation.rs::combine_takes_images_and_text_files", "crates/engine/src/tests.rs::files_for_combine_are_made_into_pdfs_and_pdfs_pass_through", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"] notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab. Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files become PDFs as they are added (#569; also in doc_combine). A file that can't be read stays in the list with why, and Combine asks before leaving it out; Office documents, HTML and other formats are not converted yet. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed." [[feature]] @@ -2992,7 +2992,7 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/organize/src/tests.rs::combine_nests_source_bookmarks_and_keeps_attachments", "crates/organize/src/tests.rs::a_file_split_around_another_is_copied_once_with_one_bookmark", "crates/organize/src/tests.rs::grouped_runs_keep_their_order_repeats_and_separate_files", "crates/engine/src/tests.rs::a_file_split_around_another_combines_in_order_with_one_bookmark", "crates/ui-egui/tests/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/automation/tests/automation.rs::combine_extract_and_split"] +evidence = ["crates/organize/src/tests.rs::combine_nests_source_bookmarks_and_keeps_attachments", "crates/organize/src/tests.rs::a_file_split_around_another_is_copied_once_with_one_bookmark", "crates/organize/src/tests.rs::grouped_runs_keep_their_order_repeats_and_separate_files", "crates/engine/src/tests.rs::a_file_split_around_another_combines_in_order_with_one_bookmark", "crates/ui-egui/tests/it/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/automation/tests/it/automation.rs::combine_extract_and_split"] notes = "Also when a file's pages are split around other files' pages (Combine files grid): the file is copied once, so it keeps one bookmark at its first page, its nested bookmarks, links between its own pages, its form fields and one copy of its attachments. doc_combine does the same with groups." [[feature]] @@ -3004,7 +3004,7 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/ui-egui/tests/editing.rs::combine_files_takes_chosen_pages_in_the_order_listed", "crates/automation/tests/automation.rs::combine_extract_and_split"] +evidence = ["crates/ui-egui/tests/it/editing.rs::combine_files_takes_chosen_pages_in_the_order_listed", "crates/automation/tests/it/automation.rs::combine_extract_and_split"] notes = "A page range per file (such as 1-3, 6; in the order given), checked as it is typed, with the page count it takes; bookmarks to left-out pages lose their destination" [[feature]] @@ -3016,7 +3016,7 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/ui-egui/tests/editing.rs::column_headings_sort_the_list_and_flip_on_the_second_click", "crates/ui-egui/tests/editing.rs::ctrl_and_shift_clicks_select_several_files_that_move_and_go_together", "crates/ui-egui/tests/editing.rs::undo_and_redo_restore_the_combine_list", "crates/automation/tests/automation.rs::combine_extract_and_split"] +evidence = ["crates/ui-egui/tests/it/editing.rs::column_headings_sort_the_list_and_flip_on_the_second_click", "crates/ui-egui/tests/it/editing.rs::ctrl_and_shift_clicks_select_several_files_that_move_and_go_together", "crates/ui-egui/tests/it/editing.rs::undo_and_redo_restore_the_combine_list", "crates/automation/tests/it/automation.rs::combine_extract_and_split"] notes = "Sort by any column (name in natural order, pages, size, modified, warnings), ascending or descending; multi-select (Ctrl/Shift) and move up/down, Alt+arrows or drag (rows in the list, cards in the grid); every change undoable. doc_combine combines in the order its paths are given." [[feature]] @@ -3028,7 +3028,7 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/ui-egui/tests/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/src/combine_grid.rs::the_cards_zoom_far_enough_to_read_a_value", "crates/ui-egui/tests/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/combine_ui.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/combine_ui.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/combine_ui.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"] +evidence = ["crates/ui-egui/tests/it/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/it/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/it/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/it/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/it/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/it/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/it/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/src/combine_grid.rs::the_cards_zoom_far_enough_to_read_a_value", "crates/ui-egui/tests/it/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/it/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/it/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/it/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/it/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/it/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/it/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/it/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/it/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/it/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/it/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/it/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/it/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/it/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/it/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/it/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/it/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/it/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/combine_ui.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/combine_ui.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/combine_ui.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/it/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/it/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/it/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"] notes = "Combine files shows a card per file (the default; Grid/List toggle kept in the settings, --combine-view grid|list): the first page the file contributes (its page range's first page), stacked when it contributes more, and its name shortened in the middle. Cards are sized from 60% to 500% (toolbar slider, buttons and percentage reset, Ctrl/⌘ +/−/0, Ctrl/⌘ with the wheel or a pinch over the grid; the buttons step 60/80/100/125/150/175/200/250/300/400/500%, kept in the settings, --combine-zoom 60..500), keeping the card under the pointer in place. 200% used to be the ceiling, which was too small to read values off an invoice while reordering the pages of a statement (#2531). Hovering a card shows its own actions along the top of its page: remove that file only (whatever else is selected; undoable) and a magnifier that shows the file large, flipping through the pages it adds (buttons, Left/Right; Esc, close or a click outside closes it), also on the card's right-click menu, and Space shows the selected file large (the way there by keyboard). Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar. Thumbnails render off the UI thread, only for cards in view, sized to both sides of the page and at most 1536 px (room for a Letter page at 500% on a 1.5 pixels-per-point screen); at most three render threads at once, counted as threads until they exit (no watchdog replacements; a page stuck for good in one step that can't be interrupted keeps its thread, and so its place, for the rest of the run); textures out of view are dropped over a 32 MB budget (those in view are kept). Locked, unreadable and bad-range files show why instead of a page, and a file whose page size can't be read (e.g. opened with an RC4 owner password) says No preview; page ranges are edited in the list. On the web a thumbnail, and the page shown large, render on the UI thread (one per frame, and only one waits at a time), so a very slow or very large page stalls that frame. A file that adds more than one page (up to 2000) expands into its pages (hover bar, right-click, or Expand all / Collapse all in the toolbar): one card per page it adds, in its range's order, each saying Page N and the file's name, on a tinted band; a click on a page selects that page, Up/Down step rows of cards, dragging a file's card moves the whole file (a drop among a file's pages goes to its nearer end), and its magnifier opens the preview on that page; expanding is a view, not an undo step, and changes nothing in the combined file; a page's render reuses its file's open document for a moment rather than reading the file again. With a file expanded, single pages are selected (click, Ctrl, Shift), dragged anywhere (a page moved between another file's pages splits its file into parts, shown as rows in the list with their own page ranges; parts side by side join again) and removed (a page card's trash, the toolbar, Delete), each one undo step, never changing the files themselves; a split file still counts and combines as one file." [[feature]] @@ -3040,7 +3040,7 @@ milestone = "M11" status = "partial" commands = ["page.combine"] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::open_documents_can_be_added_to_combine"] +evidence = ["crates/ui-egui/tests/it/editing.rs::open_documents_can_be_added_to_combine"] notes = "Include open files: Add open documents (with their unsaved changes). Missing: file-size options and converting the result to PDF/A." [[feature]] @@ -3124,7 +3124,7 @@ milestone = "M4" status = "shipped" commands = ["page.insert", "page.combine"] tools = ["doc_combine"] -evidence = ["crates/organize/src/tests.rs::import_renames_a_colliding_field_name", "crates/organize/src/tests.rs::import_renames_to_the_first_free_variant", "crates/organize/src/tests.rs::import_renames_away_from_an_orphaned_widgets_name", "crates/organize/src/tests.rs::import_renames_a_nested_field_whose_full_name_collides", "crates/organize/src/tests.rs::import_renames_the_colliding_child_not_the_whole_tree", "crates/organize/src/tests.rs::import_renames_a_field_colliding_with_another_incoming_field", "crates/organize/src/tests.rs::import_can_merge_into_same_named_fields_instead", "crates/organize/src/tests.rs::a_renamed_field_tree_survives_a_save_and_reopen", "crates/organize/src/tests.rs::a_repeated_page_joins_the_renamed_field", "crates/organize/src/tests.rs::combine_renames_colliding_field_names_and_reports_them", "crates/organize/src/tests.rs::combine_grouped_renames_colliding_field_names", "crates/organize/src/tests.rs::combine_field_conflicts_previews_each_file_in_order", "crates/engine/src/tests.rs::insert_from_file_renames_colliding_fields_and_notes_it", "crates/engine/src/tests.rs::combine_reports_renamed_fields_and_can_merge", "crates/automation/tests/automation.rs::combine_renames_colliding_field_names", "crates/ui-egui/tests/editing.rs::combine_list_warns_that_colliding_field_names_will_be_renamed"] +evidence = ["crates/organize/src/tests.rs::import_renames_a_colliding_field_name", "crates/organize/src/tests.rs::import_renames_to_the_first_free_variant", "crates/organize/src/tests.rs::import_renames_away_from_an_orphaned_widgets_name", "crates/organize/src/tests.rs::import_renames_a_nested_field_whose_full_name_collides", "crates/organize/src/tests.rs::import_renames_the_colliding_child_not_the_whole_tree", "crates/organize/src/tests.rs::import_renames_a_field_colliding_with_another_incoming_field", "crates/organize/src/tests.rs::import_can_merge_into_same_named_fields_instead", "crates/organize/src/tests.rs::a_renamed_field_tree_survives_a_save_and_reopen", "crates/organize/src/tests.rs::a_repeated_page_joins_the_renamed_field", "crates/organize/src/tests.rs::combine_renames_colliding_field_names_and_reports_them", "crates/organize/src/tests.rs::combine_grouped_renames_colliding_field_names", "crates/organize/src/tests.rs::combine_field_conflicts_previews_each_file_in_order", "crates/engine/src/tests.rs::insert_from_file_renames_colliding_fields_and_notes_it", "crates/engine/src/tests.rs::combine_reports_renamed_fields_and_can_merge", "crates/automation/tests/it/automation.rs::combine_renames_colliding_field_names", "crates/ui-egui/tests/it/editing.rs::combine_list_warns_that_colliding_field_names_will_be_renamed"] notes = "Collisions are found by fully-qualified name (parent.T.child), counting names that hang only off page widgets even when /AcroForm never lists them, and also between two incoming fields. The colliding field's own /T takes the first free variant (Text1-1, Text1-2, …), children keep their place, and nothing is silent: inserting into an open document notes each rename in the repair log (warning that scripts referring to the old names were not rewritten) and the undo step's label counts them; doc_combine lists renamed_fields in its result; the Combine tab's Warnings column previews the renames per file before combining. field_conflicts \"merge\" on doc_combine (and the engine policy) keeps the older one-field behaviour. Still missing: the Combine tab's preview considers whole files, not a narrowed page range; the UI offers no merge choice (it always renames); Create ▸ Multiple files renames without reporting; scripts and XFA bindings that reference old names are never rewritten; Acrobat's behaviour not observed." [[feature]] @@ -3148,7 +3148,7 @@ milestone = "M4" status = "shipped" commands = ["page.number"] tools = ["page_number"] -evidence = ["crates/organize/src/tests.rs::number_pages_like_acrobat", "crates/engine/src/tests.rs::number_pages_shows_in_the_viewer_and_undoes", "crates/ui-egui/tests/bookmarks.rs::number_pages_dialog_labels_the_selected_pages", "crates/automation/tests/automation.rs::numbering_pages_through_tools"] +evidence = ["crates/organize/src/tests.rs::number_pages_like_acrobat", "crates/engine/src/tests.rs::number_pages_shows_in_the_viewer_and_undoes", "crates/ui-egui/tests/it/bookmarks.rs::number_pages_dialog_labels_the_selected_pages", "crates/automation/tests/it/automation.rs::numbering_pages_through_tools"] notes = "" [[feature]] @@ -3160,7 +3160,7 @@ milestone = "M4" status = "shipped" commands = ["page.boxes"] tools = ["page_set_box"] -evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::set_page_boxes_dialog_applies_margins_to_the_chosen_pages", "crates/organize/src/tests.rs::page_boxes_default_inherit_and_set"] +evidence = ["crates/ui-egui/tests/it/pageboxes.rs::set_page_boxes_dialog_applies_margins_to_the_chosen_pages", "crates/organize/src/tests.rs::page_boxes_default_inherit_and_set"] notes = "crop/trim/bleed/art/media, margins in pt/in/mm, page ranges, preview; no Remove white margins yet" [[feature]] @@ -3172,7 +3172,7 @@ milestone = "M4" status = "shipped" commands = ["page.crop"] tools = ["page_set_box"] -evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::the_crop_tool_crops_to_the_dragged_rectangle_then_returns_to_select"] +evidence = ["crates/ui-egui/tests/it/pageboxes.rs::the_crop_tool_crops_to_the_dragged_rectangle_then_returns_to_select"] notes = "drag to crop; double-click opens Set Page Boxes" [[feature]] @@ -3208,7 +3208,7 @@ milestone = "M4" status = "partial" commands = [] tools = ["doc_info", "page_transition_set"] -evidence = ["crates/render/src/inspect.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/automation.rs::doc_info_lists_page_transitions_and_display_times", "crates/organize/src/tests.rs::page_transitions_are_set_removed_and_checked", "crates/automation/tests/automation.rs::page_transitions_are_set_and_removed_through_the_tools"] +evidence = ["crates/render/src/inspect.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/it/automation.rs::doc_info_lists_page_transitions_and_display_times", "crates/organize/src/tests.rs::page_transitions_are_set_removed_and_checked", "crates/automation/tests/it/automation.rs::page_transitions_are_set_and_removed_through_the_tools"] notes = "Read and set, not played: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults and listed by doc_info (DocInfo::transitions); page_transition_set (Edit::SetTransition, one undo step) writes or removes them on chosen pages with the values checked first. Not yet: playing them in full screen, auto-advancing on /Dur, and the Page Transitions dialog" [[feature]] @@ -3256,7 +3256,7 @@ milestone = "M4" status = "shipped" commands = ["bookmark.add"] tools = ["bookmark_add"] -evidence = ["crates/ui-egui/tests/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save", "crates/automation/tests/automation.rs::bookmarks_through_tools", "crates/ui-egui/tests/bookmark_position.rs::a_new_bookmark_goes_to_the_top_of_the_window_not_the_top_of_the_page", "crates/ui-egui/tests/bookmark_position.rs::a_bookmark_made_with_text_selected_goes_to_the_selections_corner", "crates/ui-egui/tests/bookmark_position.rs::a_bookmark_made_on_a_turned_page_pins_the_axis_the_window_scrolls_along", "crates/ui-egui/tests/bookmark_position.rs::a_bookmark_made_in_a_turned_view_pins_the_axis_that_is_vertical_on_screen", "crates/ui-egui/tests/bookmark_position.rs::a_bookmark_made_with_text_selected_in_a_turned_view_goes_to_the_corner_shown_top_left", "crates/ui-egui/tests/bookmark_position.rs::a_bookmark_in_a_spread_of_unequal_pages_goes_to_the_page_crossing_the_top_of_the_window", "crates/organize/src/tests.rs::a_bookmark_can_go_to_a_point_on_its_page_and_survives_save", "crates/engine/src/tests.rs::a_bookmark_added_at_a_point_goes_there_through_undo_and_save", "crates/automation/tests/automation.rs::a_bookmark_added_at_a_position_reports_the_same_position_back_on_any_page_rotation"] +evidence = ["crates/ui-egui/tests/it/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save", "crates/automation/tests/it/automation.rs::bookmarks_through_tools", "crates/ui-egui/tests/it/bookmark_position.rs::a_new_bookmark_goes_to_the_top_of_the_window_not_the_top_of_the_page", "crates/ui-egui/tests/it/bookmark_position.rs::a_bookmark_made_with_text_selected_goes_to_the_selections_corner", "crates/ui-egui/tests/it/bookmark_position.rs::a_bookmark_made_on_a_turned_page_pins_the_axis_the_window_scrolls_along", "crates/ui-egui/tests/it/bookmark_position.rs::a_bookmark_made_in_a_turned_view_pins_the_axis_that_is_vertical_on_screen", "crates/ui-egui/tests/it/bookmark_position.rs::a_bookmark_made_with_text_selected_in_a_turned_view_goes_to_the_corner_shown_top_left", "crates/ui-egui/tests/it/bookmark_position.rs::a_bookmark_in_a_spread_of_unequal_pages_goes_to_the_page_crossing_the_top_of_the_window", "crates/organize/src/tests.rs::a_bookmark_can_go_to_a_point_on_its_page_and_survives_save", "crates/engine/src/tests.rs::a_bookmark_added_at_a_point_goes_there_through_undo_and_save", "crates/automation/tests/it/automation.rs::a_bookmark_added_at_a_position_reports_the_same_position_back_on_any_page_rotation"] notes = "A new bookmark goes to where the reader is (#3865): the top-left of the selected text, or with nothing selected the top of the window on the page showing there, as /XYZ left top null (the reader's zoom is kept, and the horizontal position too when only the top is pinned). The window's vertical is what is pinned: on a page turned a quarter (or a view turned with View ▸ Rotate View) that is a user x, and the selection's corner is the one shown at the top-left. In a two-up spread the page crossing the window's top edge is the one used. bookmark_add takes top and left in points from the top-left of the displayed page, the same as bookmark_list reports. Missing: Set to current page (and Set Destination) still go to the page top, the zoom is not recorded, and a bookmark made while a page is only partly laid out (before the first frame) goes to the page top." [[feature]] @@ -3268,7 +3268,7 @@ milestone = "M4" status = "shipped" commands = [] tools = ["bookmark_rename"] -evidence = ["crates/ui-egui/tests/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/ui-egui/tests/bookmarks.rs::escape_cancels_a_rename", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/organize/src/tests.rs::bookmark_edits_keep_unknown_keys_and_touch_few_objects"] +evidence = ["crates/ui-egui/tests/it/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/ui-egui/tests/it/bookmarks.rs::escape_cancels_a_rename", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/organize/src/tests.rs::bookmark_edits_keep_unknown_keys_and_touch_few_objects"] notes = "" [[feature]] @@ -3280,7 +3280,7 @@ milestone = "M4" status = "shipped" commands = [] tools = ["bookmark_delete"] -evidence = ["crates/ui-egui/tests/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save"] +evidence = ["crates/ui-egui/tests/it/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save"] notes = "" [[feature]] @@ -3292,7 +3292,7 @@ milestone = "M4" status = "shipped" commands = [] tools = ["bookmark_move"] -evidence = ["crates/ui-egui/tests/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save", "crates/automation/tests/automation.rs::bookmarks_through_tools"] +evidence = ["crates/ui-egui/tests/it/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save", "crates/automation/tests/it/automation.rs::bookmarks_through_tools"] notes = "" [[feature]] @@ -3304,7 +3304,7 @@ milestone = "M4" status = "shipped" commands = [] tools = ["bookmark_set_page"] -evidence = ["crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save", "crates/automation/tests/automation.rs::bookmarks_through_tools"] +evidence = ["crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save", "crates/automation/tests/it/automation.rs::bookmarks_through_tools"] notes = "" [[feature]] @@ -3340,7 +3340,7 @@ milestone = "M4" status = "shipped" commands = ["file.properties"] tools = [] -evidence = ["crates/ui-egui/tests/bookmarks.rs::expanding_and_collapsing_all_bookmarks"] +evidence = ["crates/ui-egui/tests/it/bookmarks.rs::expanding_and_collapsing_all_bookmarks"] notes = "Bookmarks panel options: expand all, expand top-level, collapse all" [[feature]] @@ -3352,7 +3352,7 @@ milestone = "M4" status = "shipped" commands = ["bookmark.from_structure"] tools = ["bookmark_from_structure"] -evidence = ["crates/a11y/src/tests.rs::headings_are_found_with_their_levels_titles_and_pages", "crates/a11y/src/tests.rs::headings_drawn_glyph_by_glyph_keep_their_words", "crates/a11y/src/tests.rs::horizontally_scaled_wrapped_heading_keeps_a_space_between_lines", "crates/organize/src/tests.rs::a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark", "crates/engine/src/tests.rs::bookmarks_from_structure_nest_the_headings_and_undo", "crates/automation/tests/automation.rs::bookmarks_from_structure_through_tools", "crates/ui-egui/tests/bookmarks.rs::new_bookmarks_from_structure_nest_the_tagged_headings"] +evidence = ["crates/a11y/src/tests.rs::headings_are_found_with_their_levels_titles_and_pages", "crates/a11y/src/tests.rs::headings_drawn_glyph_by_glyph_keep_their_words", "crates/a11y/src/tests.rs::horizontally_scaled_wrapped_heading_keeps_a_space_between_lines", "crates/organize/src/tests.rs::a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark", "crates/engine/src/tests.rs::bookmarks_from_structure_nest_the_headings_and_undo", "crates/automation/tests/it/automation.rs::bookmarks_from_structure_through_tools", "crates/ui-egui/tests/it/bookmarks.rs::new_bookmarks_from_structure_nest_the_tagged_headings"] notes = "Bookmark options ▸ New bookmarks from structure: tagged headings (H1–H6 through the role map; a plain H takes its Sect/Part/Art nesting) become bookmarks nested by level under a new first \"Untitled\" bookmark, each linked to its element (/SE). Titles come from /ActualText, the marked text drawn directly on the page (not inside Form XObjects; words split where the glyphs are apart, so text placed glyph by glyph, as Chromium writes it, keeps its words; vertical line breaks use the absolute font size independently of Tz), /Alt or /T. Not yet: Acrobat's choice of element types (only headings), and destinations go to the heading's page rather than its position on it" [[feature]] @@ -3448,7 +3448,7 @@ milestone = "M3" status = "shipped" commands = ["file.properties"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/editing.rs::cancelling_properties_discards_the_draft"] +evidence = ["crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft"] notes = "" [[feature]] @@ -3460,7 +3460,7 @@ milestone = "M4" status = "shipped" commands = ["file.properties"] tools = ["doc_set_info"] -evidence = ["crates/organize/src/tests.rs::document_info_edits_round_trip_with_unicode", "crates/organize/src/tests.rs::document_info_edits_update_the_xmp_packet", "crates/ui-egui/tests/editing.rs::document_properties_edit_is_one_undoable_step"] +evidence = ["crates/organize/src/tests.rs::document_info_edits_round_trip_with_unicode", "crates/organize/src/tests.rs::document_info_edits_update_the_xmp_packet", "crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step"] notes = "When the file has an XMP packet, the matching property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed or removed with the /Info entry; a packet that isn't UTF-8 is left as it was. Not yet: xmp:ModifyDate and xmp:MetadataDate on save, other languages of a title kept apart (the new value replaces them)." [[feature]] @@ -3472,7 +3472,7 @@ milestone = "M3" status = "shipped" commands = ["file.properties"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/view.rs::fonts_tab_lists_fonts_and_embedding", "crates/render/src/inspect.rs::doc_info_says_which_face_stands_in_and_why", "crates/render/src/inspect.rs::an_embedded_font_reports_no_substitution_at_all"] +evidence = ["crates/ui-egui/tests/it/view.rs::fonts_tab_lists_fonts_and_embedding", "crates/render/src/inspect.rs::doc_info_says_which_face_stands_in_and_why", "crates/render/src/inspect.rs::an_embedded_font_reports_no_substitution_at_all"] notes = "name, type, embedding, subset and encoding; doc_info also reports the face standing in for a program the document does not embed and why it was chosen (Acrobat's Actual Font), including the case where no licensed face exists so nothing is drawn. Still missing: the Fonts tab in the interface does not show the substituted face yet, only the data layer does" [[feature]] @@ -3484,7 +3484,7 @@ milestone = "M4" status = "shipped" commands = ["file.properties"] tools = ["doc_initial_view"] -evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/automation.rs::initial_view_through_tools"] +evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools"] notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen)" [[feature]] @@ -3496,7 +3496,7 @@ milestone = "M4" status = "partial" commands = [] tools = ["doc_info", "doc_set_info"] -evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools"] +evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/it/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools"] notes = "Through the tools: doc_set_info sets or clears an /Info entry under any name the spec does not use (names checked as Acrobat does: no standard name, no empty name, no spaces or delimiters, at most 127 bytes), and doc_info lists them under custom, apart from the standard entries. Not yet: the Custom tab in Document Properties, and mirroring them into XMP (pdfx:) as Acrobat does" [[feature]] @@ -3520,7 +3520,7 @@ milestone = "M4" status = "shipped" commands = ["file.properties"] tools = ["doc_initial_view"] -evidence = ["crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/automation/tests/automation.rs::initial_view_through_tools"] +evidence = ["crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/automation/tests/it/automation.rs::initial_view_through_tools"] notes = "Advanced ▸ Reading Options: binding and language" [[feature]] @@ -3560,7 +3560,7 @@ milestone = "M3" status = "shipped" commands = ["comment.list"] tools = ["comment_list"] -evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/comments.rs::page_groups_collapse_and_expand"] +evidence = ["crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::page_groups_collapse_and_expand"] notes = "cards grouped by page with replies, status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet" [[feature]] @@ -3584,7 +3584,7 @@ milestone = "M5" status = "shipped" commands = ["comment.note"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"] +evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"] notes = "our own Comment/Note icon drawings, with a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline" [[feature]] @@ -3596,7 +3596,7 @@ milestone = "M5" status = "shipped" commands = ["comment.highlight"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::dragging_over_text_with_the_highlighter_highlights_it", "crates/ui-egui/tests/comments.rs::a_new_highlight_opens_its_note_for_typing", "crates/engine/src/tests.rs::highlight_multiplies_over_text"] +evidence = ["crates/ui-egui/tests/it/comments.rs::dragging_over_text_with_the_highlighter_highlights_it", "crates/ui-egui/tests/it/comments.rs::a_new_highlight_opens_its_note_for_typing", "crates/engine/src/tests.rs::highlight_multiplies_over_text"] notes = "drag over text, or pick the tool with text selected; a new highlight opens its note for typing in the Comments panel; agents can highlight by text search" [[feature]] @@ -3608,7 +3608,7 @@ milestone = "M5" status = "shipped" commands = ["comment.underline"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/ui-egui/tests/comments.rs::a_selection_made_first_is_marked_when_a_tool_is_picked"] +evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/ui-egui/tests/it/comments.rs::a_selection_made_first_is_marked_when_a_tool_is_picked"] notes = "" [[feature]] @@ -3620,7 +3620,7 @@ milestone = "M5" status = "shipped" commands = ["comment.strikeout"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::a_selection_made_first_is_marked_when_a_tool_is_picked", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"] +evidence = ["crates/ui-egui/tests/it/comments.rs::a_selection_made_first_is_marked_when_a_tool_is_picked", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"] notes = "" [[feature]] @@ -3632,7 +3632,7 @@ milestone = "M5" status = "shipped" commands = ["comment.squiggly"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/ui-egui/tests/comments.rs::the_squiggly_tool_marks_selected_text"] +evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/ui-egui/tests/it/comments.rs::the_squiggly_tool_marks_selected_text"] notes = "Highlight ▸ Squiggly underline (with Underline and Strikethrough), the agent tool and the engine" [[feature]] @@ -3644,7 +3644,7 @@ milestone = "M5" status = "shipped" commands = ["comment.replace"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::replace_text_groups_a_strikeout_and_a_caret", "crates/ui-egui/tests/comments.rs::replacing_text_strikes_it_and_adds_a_caret", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] +evidence = ["crates/annot/src/tests.rs::replace_text_groups_a_strikeout_and_a_caret", "crates/ui-egui/tests/it/comments.rs::replacing_text_strikes_it_and_adds_a_caret", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] notes = "strikeout (/IT StrikeOutTextEdit) and a grouped caret (/RT /Group) with the replacement" [[feature]] @@ -3656,7 +3656,7 @@ milestone = "M5" status = "partial" commands = ["comment.caret"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/ui-egui/tests/comments.rs::inserted_text_snaps_to_the_gap_between_characters_on_the_baseline", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] +evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/ui-egui/tests/it/comments.rs::inserted_text_snaps_to_the_gap_between_characters_on_the_baseline", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] notes = "in the UI the caret snaps to the nearer character edge, on the baseline and sized to the text, on upright horizontal text of unrotated pages; elsewhere it stays at the click. comment_add places it where it is told (no snapping)" [[feature]] @@ -3668,7 +3668,7 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/automation.rs::fill_sign_text_font_size_is_saved_and_validated"] +evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/it/automation.rs::fill_sign_text_font_size_is_saved_and_validated"] notes = "Fill & Sign text and dates have a 4–300 pt size control in the active toolbar; new items use the chosen size, which persists across launches. fill_sign_add accepts font_size for text and dates." [[feature]] @@ -3680,7 +3680,7 @@ milestone = "M5" status = "partial" commands = ["comment.freetext"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn"] +evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn"] notes = "Helvetica text with colour, size and alignment; /RC (XHTML) and /DS are written and kept in step with edits; per-run formatting (bold/italic spans) is not yet" [[feature]] @@ -3692,7 +3692,7 @@ milestone = "M5" status = "shipped" commands = ["comment.callout"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::editing_a_callout_refits_the_box_and_keeps_the_leader", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] +evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::editing_a_callout_refits_the_box_and_keeps_the_leader", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] notes = "drag from the target to the box; resizing re-attaches the leader, and so does editing the text" [[feature]] @@ -3704,7 +3704,7 @@ milestone = "M5" status = "shipped" commands = ["comment.ink"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::ink_strokes_are_smooth_curves_through_their_points", "crates/annot/src/tests.rs::drawings_resize_by_scaling_their_strokes"] +evidence = ["crates/ui-egui/tests/it/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::ink_strokes_are_smooth_curves_through_their_points", "crates/annot/src/tests.rs::drawings_resize_by_scaling_their_strokes"] notes = "one ink annotation per stroke; strokes are drawn as smooth curves through their points, as in Acrobat; resize with the 8 handles (the strokes scale into the new box)" [[feature]] @@ -3716,7 +3716,7 @@ milestone = "M5" status = "shipped" commands = ["comment.eraser"] tools = [] -evidence = ["crates/annot/src/tests.rs::the_eraser_cuts_strokes_and_removes_empty_drawings", "crates/ui-egui/tests/comments.rs::erasing_part_of_a_drawing", "crates/ui-egui/tests/comments.rs::the_eraser_rubs_as_wide_as_its_thickness_setting"] +evidence = ["crates/annot/src/tests.rs::the_eraser_cuts_strokes_and_removes_empty_drawings", "crates/ui-egui/tests/it/comments.rs::erasing_part_of_a_drawing", "crates/ui-egui/tests/it/comments.rs::the_eraser_rubs_as_wide_as_its_thickness_setting"] notes = "cuts strokes where the eraser passes; an emptied drawing is deleted; one undo step. Its thickness setting sizes it on screen (3 to 72 pixels across; the default 2 pt is 12), preview included (#3747)" [[feature]] @@ -3740,7 +3740,7 @@ milestone = "M5" status = "shipped" commands = ["comment.arrow"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging"] +evidence = ["crates/ui-egui/tests/it/comments.rs::shapes_and_ink_are_drawn_by_dragging"] notes = "" [[feature]] @@ -3752,7 +3752,7 @@ milestone = "M5" status = "shipped" commands = ["comment.square"] tools = ["comment_add", "comment_edit"] -evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance", "crates/annot/src/tests.rs::shapes_can_be_filled_and_unfilled", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/comments.rs::a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool"] +evidence = ["crates/ui-egui/tests/it/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance", "crates/annot/src/tests.rs::shapes_can_be_filled_and_unfilled", "crates/ui-egui/tests/it/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/it/comments.rs::a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool"] notes = "resize with the 8 handles, straight after drawing with the tool still on (#2338); fill colour or no fill (#686) from the quick bar, Properties and comment_edit `fill`; fill swatches are the comment palette only (no custom colour picker yet)" [[feature]] @@ -3776,7 +3776,7 @@ milestone = "M5" status = "shipped" commands = ["comment.polygon"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] +evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] notes = "click the corners; Enter, double-click or the first point finishes" [[feature]] @@ -3788,7 +3788,7 @@ milestone = "M5" status = "shipped" commands = ["comment.polyline"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] +evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] notes = "" [[feature]] @@ -3800,7 +3800,7 @@ milestone = "M5" status = "shipped" commands = ["comment.cloud"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::cloud_paths_bulge_outwards_either_way_round", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] +evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::cloud_paths_bulge_outwards_either_way_round", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] notes = "polygon with a cloudy border (/BE /S /C, /IT /PolygonCloud)" [[feature]] @@ -3812,7 +3812,7 @@ milestone = "M5" status = "shipped" commands = ["comment.highlight"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::highlighting_an_area_off_the_text"] +evidence = ["crates/ui-egui/tests/it/comments.rs::highlighting_an_area_off_the_text"] notes = "drag the highlighter off text to highlight a box" [[feature]] @@ -3824,7 +3824,7 @@ milestone = "M5" status = "shipped" commands = ["comment.attach"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/ui-egui/tests/comments.rs::an_attachment_comment_opens_its_file_from_the_page_menu", "crates/ui-egui/tests/comments.rs::an_attachment_comment_opens_its_file_from_the_card_menu", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] +evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/it/comments.rs::attaching_a_file_as_a_comment", "crates/ui-egui/tests/it/comments.rs::an_attachment_comment_opens_its_file_from_the_page_menu", "crates/ui-egui/tests/it/comments.rs::an_attachment_comment_opens_its_file_from_the_card_menu", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel. The comment's menu, on the page and in the Comments panel, saves the file or opens a PDF in a new tab (#2565)" [[feature]] @@ -3860,7 +3860,7 @@ milestone = "M5" status = "partial" commands = [] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::every_line_ending_is_drawn", "crates/automation/tests/automation.rs::line_endings_through_the_tool", "crates/ui-egui/tests/comments.rs::line_ending_properties_change_and_undo"] +evidence = ["crates/annot/src/tests.rs::every_line_ending_is_drawn", "crates/automation/tests/it/automation.rs::line_endings_through_the_tool", "crates/ui-egui/tests/it/comments.rs::line_ending_properties_change_and_undo"] notes = "All ten /LE styles (None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash) are drawn on lines, polylines and callouts. comment_add endings and Comment properties set them. Dashed borders are drawn. Cloudy borders are still polygon-only." [[feature]] @@ -3872,7 +3872,7 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_edit"] -evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/comments.rs::text_box_properties_change_the_font_size", "crates/ui-egui/tests/comments.rs::text_box_and_callout_properties_include_fill_and_thickness", "crates/annot/src/tests.rs::text_boxes_and_callouts_can_change_fill_and_border", "crates/automation/tests/automation.rs::text_box_and_callout_fill_and_border_through_tools", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/automation.rs::text_box_font_size_through_the_tool"] +evidence = ["crates/ui-egui/tests/it/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/it/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/it/comments.rs::text_box_properties_change_the_font_size", "crates/ui-egui/tests/it/comments.rs::text_box_and_callout_properties_include_fill_and_thickness", "crates/annot/src/tests.rs::text_boxes_and_callouts_can_change_fill_and_border", "crates/automation/tests/it/automation.rs::text_box_and_callout_fill_and_border_through_tools", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/it/automation.rs::text_box_font_size_through_the_tool"] notes = "Appearance (colour, fill colour for rectangles/ovals/polygons/text boxes/callouts, opacity, thickness, note icon, font size of text boxes, typewriter text and callouts: 1-400 pt, written to /DA and the rich-text strings, the box grows to fit and never shrinks; comment_edit font_size), General (author, subject), Review History. Still missing: font family, bold/italic, alignment and per-run formatting of text boxes; the quick bar has no font size for new text boxes (they start at 12 pt, callouts 10 pt)" [[feature]] @@ -3884,7 +3884,7 @@ milestone = "M5" status = "shipped" commands = ["comment.note"] tools = ["comment_edit"] -evidence = ["crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance", "crates/ui-egui/tests/comments.rs::the_opacity_set_for_a_tool_goes_into_its_new_comments", "crates/ui-egui/tests/comments.rs::the_quick_bar_colour_changes_the_selected_comment", "crates/ui-egui/tests/comments.rs::the_quick_bar_colour_changes_the_selected_arrow"] +evidence = ["crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance", "crates/ui-egui/tests/it/comments.rs::the_opacity_set_for_a_tool_goes_into_its_new_comments", "crates/ui-egui/tests/it/comments.rs::the_quick_bar_colour_changes_the_selected_comment", "crates/ui-egui/tests/it/comments.rs::the_quick_bar_colour_changes_the_selected_arrow"] notes = "quick-bar colour swatches immediately recolour a selected comment made with the active tool, and set the tool default; fill colour (rectangle, oval, polygon, cloud), opacity (10–100 %) and line thickness per tool; colour from the context menu" [[feature]] @@ -3896,7 +3896,7 @@ milestone = "M5" status = "shipped" commands = [] tools = ["comment_lock"] -evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"] +evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/it/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/it/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"] notes = "Comment Properties ▸ Locked; locked comments refuse move, resize, restyle and delete; text stays editable" [[feature]] @@ -3908,7 +3908,7 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = [] -evidence = ["crates/ui-egui/tests/comments.rs::make_current_properties_default", "crates/ui-egui/tests/comments.rs::properties_dialog_makes_the_strikethrough_colour_default"] +evidence = ["crates/ui-egui/tests/it/comments.rs::make_current_properties_default", "crates/ui-egui/tests/it/comments.rs::properties_dialog_makes_the_strikethrough_colour_default"] notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus, and Acrobat's Make Properties Default box in Comment Properties, applied on OK with the edited look) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340)" [[feature]] @@ -3920,7 +3920,7 @@ milestone = "M5" status = "shipped" commands = [] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::dragging_over_text_with_the_highlighter_highlights_it", "crates/ui-egui/tests/ui_group/identity.rs::the_identity_name_signs_new_comments_and_is_remembered"] +evidence = ["crates/ui-egui/tests/it/comments.rs::dragging_over_text_with_the_highlighter_highlights_it", "crates/ui-egui/tests/it/identity.rs::the_identity_name_signs_new_comments_and_is_remembered"] notes = "Preferences ▸ Identity: the name on new comments (default the login name, as Acrobat), remembered across restarts" [[feature]] @@ -3932,7 +3932,7 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_reply"] -evidence = ["crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/comments.rs::ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent"] +evidence = ["crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent"] notes = "multiline reply field: Enter or Ctrl+Enter (also Command+Enter) posts, Shift+Enter adds a newline; empty replies are not posted" [[feature]] @@ -3956,7 +3956,7 @@ milestone = "M5" status = "shipped" commands = [] tools = ["comment_mark"] -evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"] +evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/it/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/it/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"] notes = "hidden /StateModel /Marked reply; card toggle and context menu" [[feature]] @@ -3968,7 +3968,7 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_delete"] -evidence = ["crates/ui-egui/tests/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"] +evidence = ["crates/ui-egui/tests/it/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"] notes = "deletes the pop-up and the reply thread" [[feature]] @@ -3980,7 +3980,7 @@ milestone = "M5" status = "shipped" commands = [] tools = ["comment_edit"] -evidence = ["crates/annot/src/tests.rs::lines_and_polygons_are_reshaped_by_their_points", "crates/ui-egui/tests/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/automation.rs::comments_are_reshaped_through_the_tool"] +evidence = ["crates/annot/src/tests.rs::lines_and_polygons_are_reshaped_by_their_points", "crates/ui-egui/tests/it/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/it/automation.rs::comments_are_reshaped_through_the_tool"] notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it to 45° from the point before), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner." [[feature]] @@ -3992,7 +3992,7 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_edit"] -evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/automation.rs::comments_through_tools", "crates/ui-egui/tests/comments.rs::ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line"] +evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/it/automation.rs::comments_through_tools", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line"] notes = "double-click a comment or card, or Edit in the menus; Ctrl+Enter (also Command+Enter) saves text in the focused comment editor, Shift+Enter adds a newline; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines)" [[feature]] @@ -4004,7 +4004,7 @@ milestone = "M5" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize"] +evidence = ["crates/ui-egui/tests/it/comments.rs::comments_take_checkmarks_lock_hide_and_summarize"] notes = "card and canvas context menus" [[feature]] @@ -4016,7 +4016,7 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_list"] -evidence = ["crates/ui-egui/tests/comments.rs::the_panel_filters_and_sorts", "crates/ui-egui/tests/comments.rs::the_panel_filters_by_colour_and_checkmark"] +evidence = ["crates/ui-egui/tests/it/comments.rs::the_panel_filters_and_sorts", "crates/ui-egui/tests/it/comments.rs::the_panel_filters_by_colour_and_checkmark"] notes = "type, author, status, colour and checkmark" [[feature]] @@ -4028,7 +4028,7 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_list"] -evidence = ["crates/ui-egui/tests/comments.rs::the_panel_filters_and_sorts"] +evidence = ["crates/ui-egui/tests/it/comments.rs::the_panel_filters_and_sorts"] notes = "page, author, date, type, colour, with group headers" [[feature]] @@ -4040,7 +4040,7 @@ milestone = "M5" status = "partial" commands = ["comment.list"] tools = ["comment_list"] -evidence = ["crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies"] +evidence = ["crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies"] notes = "search by author, text and type in the panel; not yet in the Find bar" [[feature]] @@ -4052,7 +4052,7 @@ milestone = "M5" status = "partial" commands = ["comment.hide_all"] tools = ["comments_hide"] -evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"] +evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/it/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/it/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"] notes = "hide/show all comments (fields and links still draw); per-type hiding not yet" [[feature]] @@ -4088,7 +4088,7 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_list"] -evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author"] +evidence = ["crates/ui-egui/tests/it/comments.rs::comment_properties_change_appearance_and_author"] notes = "status changes listed in Comment Properties" [[feature]] @@ -4100,7 +4100,7 @@ milestone = "M5" status = "shipped" commands = ["comment.stamp"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"] +evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/it/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/it/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"] notes = "12 standard business stamps in PdfCraft's own design; ISO 32000 stamp names where defined. A stamp placed on a rotated page reads upright as the page is displayed (#1398)" [[feature]] @@ -4112,7 +4112,7 @@ milestone = "M5" status = "shipped" commands = ["comment.stamp"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"] +evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/it/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/it/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"] notes = "Accepted, Initial Here, Rejected, Sign Here, Witness as pointed tags. The point stays on the left as the page is displayed, including /Rotate 90, 180 and 270 (#1398)" [[feature]] @@ -4124,7 +4124,7 @@ milestone = "M6" status = "shipped" commands = ["comment.stamp"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"] +evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/it/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/it/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"] notes = "Approved, Confidential, Received, Reviewed, Revised with a By author at time, date line (local time). The label is turned with the page's /Rotate so it reads upright as displayed (#1398)" [[feature]] @@ -4136,7 +4136,7 @@ milestone = "M5" status = "shipped" commands = ["comment.stamp"] tools = ["stamp_custom"] -evidence = ["crates/ui-egui/tests/stamps.rs::creating_placing_and_keeping_custom_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::explicit_image_stamps_keep_displayed_orientation_on_rotated_pages", "crates/engine/src/tests.rs::pdf_page_stamps_keep_displayed_orientation_on_rotated_pages"] +evidence = ["crates/ui-egui/tests/it/stamps.rs::creating_placing_and_keeping_custom_stamps", "crates/automation/tests/it/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::explicit_image_stamps_keep_displayed_orientation_on_rotated_pages", "crates/engine/src/tests.rs::pdf_page_stamps_keep_displayed_orientation_on_rotated_pages"] notes = "Create custom stamp from a PDF page or an image (category and name), kept in the app's settings; placed at natural size (at most 200 pt) keeping its shape; resizable (appearance redrawn from /PCPicture); delete from the palette. Natural-size and explicit-rectangle images, and PDF-page stamps, read upright on a rotated page (#1398)" [[feature]] @@ -4172,7 +4172,7 @@ milestone = "M5" status = "shipped" commands = ["comment.import"] tools = ["doc_import_data"] -evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] +evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/it/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] notes = "replies, pop-ups, ink, quads, line ends; same /NM replaces" [[feature]] @@ -4184,7 +4184,7 @@ milestone = "M5" status = "shipped" commands = ["comment.export"] tools = ["doc_export_data"] -evidence = ["crates/xfdf/src/tests.rs::fdf_export_nulls_nested_page_links_without_copying_the_source_page_tree", "crates/xfdf/src/tests.rs::fdf_export_bounds_indirect_type_alias_lookups", "crates/xfdf/src/tests.rs::fdf_export_preserves_metadata_with_stale_type_references", "crates/xfdf/src/tests.rs::fdf_export_keeps_indirect_values_nested_in_annotation_properties", "crates/xfdf/src/tests.rs::fdf_export_preserves_shared_cycles_and_nested_streams_without_expansion", "crates/xfdf/src/tests.rs::fdf_export_refuses_excessive_direct_depth_values_and_payload", "crates/xfdf/src/tests.rs::fdf_export_refuses_aliases_before_cloning_large_annotation_values", "crates/xfdf/src/tests.rs::fdf_export_refuses_repeated_saved_annotations_after_cache_eviction", "crates/automation/tests/automation.rs::fdf_nested_comment_values_survive_export_import_and_save_through_tools", "crates/automation/tests/automation.rs::refused_fdf_export_keeps_the_destination_and_source_through_tools", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xfdf_exports_preserve_whitespace_in_comment_attributes"] +evidence = ["crates/xfdf/src/tests.rs::fdf_export_nulls_nested_page_links_without_copying_the_source_page_tree", "crates/xfdf/src/tests.rs::fdf_export_bounds_indirect_type_alias_lookups", "crates/xfdf/src/tests.rs::fdf_export_preserves_metadata_with_stale_type_references", "crates/xfdf/src/tests.rs::fdf_export_keeps_indirect_values_nested_in_annotation_properties", "crates/xfdf/src/tests.rs::fdf_export_preserves_shared_cycles_and_nested_streams_without_expansion", "crates/xfdf/src/tests.rs::fdf_export_refuses_excessive_direct_depth_values_and_payload", "crates/xfdf/src/tests.rs::fdf_export_refuses_aliases_before_cloning_large_annotation_values", "crates/xfdf/src/tests.rs::fdf_export_refuses_repeated_saved_annotations_after_cache_eviction", "crates/automation/tests/it/automation.rs::fdf_nested_comment_values_survive_export_import_and_save_through_tools", "crates/automation/tests/it/automation.rs::refused_fdf_export_keeps_the_destination_and_source_through_tools", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/it/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xfdf_exports_preserve_whitespace_in_comment_attributes"] notes = "FDF preserves shared/cyclic nested values. Discovery keeps lazy array owners and annotation positions; retained entries are budgeted before cloning, including after saved-PDF cache eviction. Copied-graph limits are 100,000 comments/objects/values, 64 direct levels and a conservative 64 MiB data budget; refusal precedes writing. Source parsing and page discovery retain existing COS/parser limits, with no total source-memory bound. Source /Page and /Pages dictionaries become null at every copy depth; indirect Type names check each reference generation with iterative cycle detection and a 64-reference limit; stale Type references preserve unrelated metadata; a saved/reopened three-page fixture verifies small output and retained unrelated metadata. Appearances and source-document links are not carried; FDF import semantics are unchanged. XFDF exports retain tabs and line breaks in comment identities and metadata." [[feature]] @@ -4220,7 +4220,7 @@ milestone = "M5" status = "partial" commands = ["comment.summarize"] tools = ["comments_summarize"] -evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"] +evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/it/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/it/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"] notes = "Comments only layout, sorted by page, author, date or type; the layouts with the document and connector lines not yet" [[feature]] @@ -4232,7 +4232,7 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_flatten"] -evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments"] +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/it/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments"] notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)" [[feature]] @@ -4256,7 +4256,7 @@ milestone = "M12" status = "partial" commands = ["measure.distance"] tools = ["measure_distance"] -evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] +evidence = ["crates/automation/tests/it/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/it/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] notes = "Two-point distance annotations with saved numeric captions and rectilinear scales. Partial: only single-unit decimal formats; compound (ft-in) and fractional formats are not supported (listed as unsupported, data kept), no leader lines or caption placement options, geospatial scales unsupported, and interop with Acrobat-authored measurements (reading theirs, Acrobat reading ours) is untested." [[feature]] @@ -4268,7 +4268,7 @@ milestone = "M12" status = "shipped" commands = ["measure.perimeter"] tools = ["measure_perimeter"] -evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] +evidence = ["crates/automation/tests/it/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/it/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] notes = "Connected-line lengths with click vertices and double-click/Enter finish. Repeat the first point to include a closing edge. Saved standard PolyLineDimension annotations." [[feature]] @@ -4280,7 +4280,7 @@ milestone = "M12" status = "shipped" commands = ["measure.area"] tools = ["measure_area"] -evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] +evidence = ["crates/automation/tests/it/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/it/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] notes = "Simple polygon areas, including concave boundaries and either winding. Self-intersecting boundaries are refused. Saved PolygonDimension annotations; no 3D surface measurement." [[feature]] @@ -4292,7 +4292,7 @@ milestone = "M12" status = "partial" commands = ["measure.scale"] tools = ["measure_scale"] -evidence = ["crates/measure/src/tests.rs::viewport_precedence_first_point_user_unit_and_roundtrip", "crates/measure/src/tests.rs::coordinates_roundtrip_for_crop_rotation_and_user_unit", "crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] +evidence = ["crates/measure/src/tests.rs::viewport_precedence_first_point_user_unit_and_roundtrip", "crates/measure/src/tests.rs::coordinates_roundtrip_for_crop_rotation_and_user_unit", "crates/automation/tests/it/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/ui-egui/tests/it/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] notes = "Persistent rectangular viewports with last-containing-viewport precedence, numeric scale and two-point calibration. First vertex selects the scale; prior annotations retain theirs. Partial: single-unit decimal rectilinear scales only; compound (ft-in), fractional and differing distance units can't be authored or imported, no viewport editing/removal UI, and interop with Acrobat's scale ratio dialog and its saved viewports is untested." [[feature]] @@ -4304,7 +4304,7 @@ milestone = "M12" status = "shipped" commands = ["measure.snap"] tools = ["measure_snap"] -evidence = ["crates/automation/tests/automation.rs::measurement_snap_tool_covers_all_targets", "crates/measure/src/tests.rs::nested_forms_transforms_curves_and_cycles_are_bounded", "crates/measure/src/tests.rs::snap_paths_endpoints_midpoints_and_intersections"] +evidence = ["crates/automation/tests/it/automation.rs::measurement_snap_tool_covers_all_targets", "crates/measure/src/tests.rs::nested_forms_transforms_curves_and_cycles_are_bounded", "crates/measure/src/tests.rs::snap_paths_endpoints_midpoints_and_intersections"] notes = "Paths, endpoints, midpoints and intersections of painted vector geometry, including transformed Form XObjects. Cubics flattened to 0.2 user units. Bounded extraction and dense-intersection limits are reported. Raster/text outlines, clipping and optional-content visibility are not interpreted." [[feature]] @@ -4316,7 +4316,7 @@ milestone = "M12" status = "shipped" commands = ["measure.info"] tools = ["measure_info", "measure_list"] -evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] +evidence = ["crates/automation/tests/it/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/ui-egui/tests/it/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] notes = "Live value, units, X/Y deltas, angle and selected scale while drawing; saved measurements can be selected from the panel. Rectilinear 2D readings only." [[feature]] @@ -4328,7 +4328,7 @@ milestone = "M12" status = "shipped" commands = ["measure.export"] tools = ["measure_export"] -evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::measurement_csv_quotes_newlines_and_blocks_formula_cells", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] +evidence = ["crates/automation/tests/it/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::measurement_csv_quotes_newlines_and_blocks_formula_cells", "crates/ui-egui/tests/it/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] notes = "Atomic CSV export of page, annotation index, type, numeric value, unit, label, author and scale. Quotes embedded delimiters and prefixes spreadsheet formula triggers." [[feature]] @@ -4404,7 +4404,7 @@ milestone = "M3" status = "shipped" commands = ["form.fields"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect.rs::fields_listed_only_on_the_pages_are_listed", "crates/ui-egui/tests/ui_group/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/ui_group/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"] +evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect.rs::fields_listed_only_on_the_pages_are_listed", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"] notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too" [[feature]] @@ -4416,7 +4416,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/view.rs::field_highlighting_is_on_by_default_and_remembered", "crates/ui-egui/tests/view.rs::stored_field_highlight_choice_wins_over_the_default", "crates/ui-egui/tests/view.rs::highlight_fields_leaves_read_only_fields_alone", "crates/ui-egui/tests/editing.rs::a_secured_form_shows_its_form_notice_and_its_security"] +evidence = ["crates/ui-egui/tests/it/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/it/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/it/view.rs::field_highlighting_is_on_by_default_and_remembered", "crates/ui-egui/tests/it/view.rs::stored_field_highlight_choice_wins_over_the_default", "crates/ui-egui/tests/it/view.rs::highlight_fields_leaves_read_only_fields_alone", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security"] notes = "only fields that can be filled are marked (a read-only field is left as drawn, so a form's locked boxes don't look fillable); offered on secured forms too; on by default for new settings (a stored choice is kept) and remembered for the next document and session; toggled from the form bar and from Preferences > Appearance, where it sets the default for documents opened afterwards; required fields get a red border (round for radio buttons); the tint is drawn over the field, where Acrobat paints the highlight colour behind it" [[feature]] @@ -4440,7 +4440,7 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit"] +evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit"] notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced." [[feature]] @@ -4452,7 +4452,7 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill"] -evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states", "crates/ui-egui/tests/forms.rs::tab_and_space_fill_in_a_check_box_between_text_fields", "crates/ui-egui/tests/forms.rs::clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box", "crates/ui-egui/tests/forms.rs::radio_buttons_take_tab_space_and_arrows"] +evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states", "crates/ui-egui/tests/it/forms.rs::tab_and_space_fill_in_a_check_box_between_text_fields", "crates/ui-egui/tests/it/forms.rs::clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box", "crates/ui-egui/tests/it/forms.rs::radio_buttons_take_tab_space_and_arrows"] notes = "NoToggleToOff honoured; widgets without appearances get our own. Tab and Shift+Tab stop on check boxes and on each radio button, Space toggles the focused one, and the arrows move within a radio group (#2223); a click focuses the box too" [[feature]] @@ -4464,7 +4464,7 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill"] -evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"] +evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"] notes = "combo and list boxes, multi-select lists; editable combo boxes accept free text" [[feature]] @@ -4476,7 +4476,7 @@ milestone = "M6" status = "partial" commands = ["form.fields"] tools = [] -evidence = ["crates/ui-egui/tests/buttons.rs::buttons_reset_navigate_and_print", "crates/forms/src/af.rs::common_button_scripts_are_recognised", "crates/ui-egui/tests/javascript.rs::button_scripts_run_and_alert"] +evidence = ["crates/ui-egui/tests/it/buttons.rs::buttons_reset_navigate_and_print", "crates/forms/src/af.rs::common_button_scripts_are_recognised", "crates/ui-egui/tests/it/javascript.rs::button_scripts_run_and_alert"] notes = "Reset form, Named (print, page navigation), URI, Go to, and any JavaScript (run in the sandboxed engine); submit explained, not sent" [[feature]] @@ -4488,7 +4488,7 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = [] -evidence = ["crates/ui-egui/tests/forms.rs::tabbing_into_a_filled_field_selects_it_so_typing_replaces"] +evidence = ["crates/ui-egui/tests/it/forms.rs::tabbing_into_a_filled_field_selects_it_so_typing_replaces"] notes = "Tab / Shift+Tab move between text and choice fields in field order; entering with Tab selects the value" [[feature]] @@ -4500,7 +4500,7 @@ milestone = "M6" status = "partial" commands = ["form.fields"] tools = ["form_fill", "form_set_props"] -evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::auto_text_size_fits_single_and_multiline_text", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/engine/tests/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/prepare.rs::list_box_alignment_is_editable_and_preserved"] +evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::auto_text_size_fits_single_and_multiline_text", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/engine/tests/it/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/it/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/it/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/it/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/it/prepare.rs::list_box_alignment_is_editable_and_preserved"] notes = "auto-size, comb, multiline, password, quadding (text, dropdown and list-box Options controls), /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics" [[feature]] @@ -4524,7 +4524,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::numbers_format_like_acrobat", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"] +evidence = ["crates/forms/src/af.rs::numbers_format_like_acrobat", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] notes = "AFNumber_Format/Keystroke run natively: decimals, 5 separator styles, currency before/after, negative styles (red shown as minus/parentheses)" [[feature]] @@ -4572,7 +4572,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"] +evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] notes = "AFRange_Validate with Acrobat's messages" [[feature]] @@ -4596,7 +4596,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"] +evidence = ["crates/forms/src/af.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] notes = "BVCALC expressions with + - * / and parentheses, escaped names" [[feature]] @@ -4608,7 +4608,7 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_set_script"] -evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"] +evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools"] notes = "Custom keystroke (commit), validate, calculate and format scripts run in Acrobat's event order; AF calls still run natively." [[feature]] @@ -4620,7 +4620,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties", "form.calculation_order"] tools = ["form_set_props", "form_fill", "form_calculation_order"] -evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/tests.rs::the_calculation_order_is_checked_fixed_and_kept", "crates/forms/src/tests.rs::a_calculation_order_with_gaps_and_extra_entries", "crates/forms/src/tests.rs::two_fields_with_one_name_keep_their_places_in_the_order", "crates/engine/src/tests.rs::setting_the_calculation_order_recalculates_and_undoes", "crates/automation/tests/automation.rs::the_calculation_order_is_read_fixed_and_set", "crates/ui-egui/tests/prepare.rs::the_calculation_order_is_moved_dragged_and_fixed", "crates/engine/src/tests.rs::checkbox_and_rank_changes_recalculate_ordered_dependents", "crates/engine/src/tests.rs::dynamic_calculation_reads_and_side_effects_reach_later_fields"] +evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/tests.rs::the_calculation_order_is_checked_fixed_and_kept", "crates/forms/src/tests.rs::a_calculation_order_with_gaps_and_extra_entries", "crates/forms/src/tests.rs::two_fields_with_one_name_keep_their_places_in_the_order", "crates/engine/src/tests.rs::setting_the_calculation_order_recalculates_and_undoes", "crates/automation/tests/it/automation.rs::the_calculation_order_is_read_fixed_and_set", "crates/ui-egui/tests/it/prepare.rs::the_calculation_order_is_moved_dragged_and_fixed", "crates/engine/src/tests.rs::checkbox_and_rank_changes_recalculate_ordered_dependents", "crates/engine/src/tests.rs::dynamic_calculation_reads_and_side_effects_reach_later_fields"] notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form. Order editing: Prepare a form ▸ Calculation order lists the calculated fields in order with their formulas and values, Up/Down arrows (as Acrobat's Set Field Calculation Order dialog), Alt+Up/Down and drag, says which fields read a field calculated later, which read each other in a loop and which /CO leaves out, and Fix order moves only what must move; on the page it numbers each calculated field's step and draws arrows from the fields the focused field reads and to the fields that read it; form_calculation_order reads, sets, moves and fixes it. A new calculation joins the end of /CO and an edited one keeps its place. Acrobat's dialog is known from Adobe's help summary only, not yet observed." [[feature]] @@ -4644,7 +4644,7 @@ milestone = "M6" status = "shipped" commands = [] tools = ["form_fill"] -evidence = ["crates/ui-egui/tests/forms.rs::date_fields_offer_a_calendar", "crates/ui-egui/tests/forms.rs::the_calendar_follows_a_complete_date_typed_while_it_is_open"] +evidence = ["crates/ui-egui/tests/it/forms.rs::date_fields_offer_a_calendar", "crates/ui-egui/tests/it/forms.rs::the_calendar_follows_a_complete_date_typed_while_it_is_open"] notes = "a month calendar under date fields; the pick is written in the field's own date format; it opens on the field's date and moves to a whole date typed while it is open, staying on the month its arrows turned to otherwise" [[feature]] @@ -4668,7 +4668,7 @@ milestone = "M6" status = "shipped" commands = [] tools = ["form_set_props"] -evidence = ["crates/ui-egui/tests/view.rs::required_fields_get_a_red_border_when_highlighting"] +evidence = ["crates/ui-egui/tests/it/view.rs::required_fields_get_a_red_border_when_highlighting"] notes = "required fields get a red border when fields are highlighted" [[feature]] @@ -4680,7 +4680,7 @@ milestone = "M6" status = "shipped" commands = ["form.clear"] tools = ["form_reset"] -evidence = ["crates/forms/src/tests.rs::reset_restores_defaults", "crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices"] +evidence = ["crates/forms/src/tests.rs::reset_restores_defaults", "crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices"] notes = "back to /DV; undoable" [[feature]] @@ -4704,7 +4704,7 @@ milestone = "M6" status = "shipped" commands = ["form.import_data"] tools = ["doc_import_data"] -evidence = ["crates/xfdf/src/tests.rs::xfdf_import_keeps_text_around_xml_comments_and_instructions", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] +evidence = ["crates/xfdf/src/tests.rs::xfdf_import_keeps_text_around_xml_comments_and_instructions", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/it/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] notes = "XFDF, FDF; XFDF text survives XML comments and processing instructions; values go through formats and validation; read-only fields take data; XFA forms also take XDP/XML data files (form.xfa-data)" [[feature]] @@ -4716,7 +4716,7 @@ milestone = "M6" status = "shipped" commands = ["form.export_data"] tools = ["doc_export_data"] -evidence = ["crates/xfdf/src/tests.rs::fdf_export_counts_emitted_field_values_before_allocating_lists", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names"] +evidence = ["crates/xfdf/src/tests.rs::fdf_export_counts_emitted_field_values_before_allocating_lists", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/it/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names"] notes = "XFDF, FDF; FDF counts emitted field values, including every multi-select list entry, before allocating the output list and refuses exports beyond its shared 100,000-value budget. Existing source-field parsing limits remain unchanged. XFA forms also export their data as XML or XDP (form.xfa-data). XML/XFDF exports retain tabs and line breaks in field identities through character references." [[feature]] @@ -4728,7 +4728,7 @@ milestone = "M6" status = "shipped" commands = ["form.export_data", "form.import_data"] tools = ["doc_export_data", "doc_import_data"] -evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names", "crates/xfdf/src/tests.rs::xml_form_import_preserves_text_around_comments_and_instructions", "crates/xfdf/src/tests.rs::csv_headers_with_tabs_survive_export_import_and_save", "crates/xfdf/src/tests.rs::tabular_import_keeps_tsv_quotes_literal_and_csv_value_tabs"] +evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/it/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names", "crates/xfdf/src/tests.rs::xml_form_import_preserves_text_around_comments_and_instructions", "crates/xfdf/src/tests.rs::csv_headers_with_tabs_survive_export_import_and_save", "crates/xfdf/src/tests.rs::tabular_import_keeps_tsv_quotes_literal_and_csv_value_tabs"] notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited; XML/XFDF exports retain tabs and line breaks in field identities through character references; plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Nested-element and XFA interpretation remain outside this flat exchange format. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Ambiguous externally authored tables remain heuristic." [[feature]] @@ -4740,7 +4740,7 @@ milestone = "M6" status = "shipped" commands = ["form.merge_data"] tools = ["form_merge_data"] -evidence = ["crates/xfdf/src/tests.rs::data_files_merge_into_a_spreadsheet", "crates/automation/tests/automation.rs::merging_form_data_into_a_spreadsheet", "crates/ui-egui/tests/javascript.rs::merge_data_files_into_a_spreadsheet", "crates/xfdf/src/tests.rs::csv_headers_with_tabs_survive_export_import_and_save"] +evidence = ["crates/xfdf/src/tests.rs::data_files_merge_into_a_spreadsheet", "crates/automation/tests/it/automation.rs::merging_form_data_into_a_spreadsheet", "crates/ui-egui/tests/it/javascript.rs::merge_data_files_into_a_spreadsheet", "crates/xfdf/src/tests.rs::csv_headers_with_tabs_survive_export_import_and_save"] notes = "FDF, XFDF or filled-in PDF forms \u2192 CSV, a column per field and a row per file. CSV header tabs, quotes and line breaks retain field identities through import and save/reopen; ambiguous external table detection remains heuristic." [[feature]] @@ -4752,7 +4752,7 @@ milestone = "M6" status = "shipped" commands = ["form.flatten"] tools = ["doc_flatten"] -evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_a_valued_widget_it_cannot_draw", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_a_valued_widget_it_cannot_draw", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/it/automation.rs::flattening_through_tools"] notes = "widgets drawn into content and pruned from the field tree; a widget with no appearance to draw (none stored, or none for its selected state) stays a live field rather than losing its value, so a form with such fields is not fully flattened — Acrobat regenerates appearances first, which PdfCraft does not yet do here" [[feature]] @@ -4764,7 +4764,7 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_add_field", "form_set_props", "form_delete_field"] -evidence = ["crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/prepare.rs::arrow_keys_nudge_the_selected_field_instead_of_turning_the_page", "crates/ui-egui/src/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/ui_group/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] +evidence = ["crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/it/prepare.rs::arrow_keys_nudge_the_selected_field_instead_of_turning_the_page", "crates/ui-egui/src/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/it/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/it/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/it/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/it/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] notes = "fields shown with blue fill and name tags; select, move (drag, or the arrow keys: a point, Shift ten points, #2608), resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits" [[feature]] @@ -4776,7 +4776,7 @@ milestone = "M6" status = "shipped" commands = ["form.add.text"] tools = ["form_add_field"] -evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"] +evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools"] notes = "click for the default size or drag; named Text1, Text2…" [[feature]] @@ -4788,7 +4788,7 @@ milestone = "M6" status = "shipped" commands = ["form.add.checkbox"] tools = ["form_add_field"] -evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field"] +evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field"] notes = "Check Box1…, Yes/Off appearance states" [[feature]] @@ -4800,7 +4800,7 @@ milestone = "M6" status = "shipped" commands = ["form.add.radio"] tools = ["form_add_field"] -evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"] +evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools"] notes = "new group Group1… or join the selected group with the next ChoiceN" [[feature]] @@ -4812,7 +4812,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fields"] -evidence = ["crates/forms/src/tests.rs::check_box_styles_change_the_mark", "crates/ui-egui/tests/prepare.rs::options_tab_sets_the_check_box_style", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"] +evidence = ["crates/forms/src/tests.rs::check_box_styles_change_the_mark", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_the_check_box_style", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools"] notes = "Field Properties ▸ Options: check, circle, cross, diamond, square, star (/MK /CA, Acrobat's codes), drawn as paths; defaults check (check boxes) and circle (radio buttons)" [[feature]] @@ -4824,7 +4824,7 @@ milestone = "M6" status = "shipped" commands = ["form.add.combo"] tools = ["form_add_field"] -evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"] +evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools"] notes = "Dropdown1…; items in Field Properties ▸ Options" [[feature]] @@ -4860,7 +4860,7 @@ milestone = "M6" status = "shipped" commands = ["form.add.image"] tools = ["form_add_field", "form_set_image"] -evidence = ["crates/forms/src/tests.rs::image_fields_ask_for_a_picture_and_show_it", "crates/ui-egui/tests/prepare.rs::image_fields_take_a_picture_when_clicked", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"] +evidence = ["crates/forms/src/tests.rs::image_fields_ask_for_a_picture_and_show_it", "crates/ui-egui/tests/it/prepare.rs::image_fields_take_a_picture_when_clicked", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools"] notes = "icon-only push button with event.target.buttonImportIcon(), recognised natively; clicking asks for a PNG/JPEG/TIFF/GIF/BMP that becomes /MK /I, scaled proportionally and centred" [[feature]] @@ -4908,7 +4908,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/ui-egui/tests/ui_group/bulk_fields.rs::locked_field_refuses_the_entire_batch_and_keeps_the_draft_for_correction", "crates/ui-egui/tests/ui_group/bulk_fields.rs::visiting_shared_property_tabs_without_changes_does_not_dirty_the_document"] +evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/ui-egui/tests/it/bulk_fields.rs::locked_field_refuses_the_entire_batch_and_keeps_the_draft_for_correction", "crates/ui-egui/tests/it/bulk_fields.rs::visiting_shared_property_tabs_without_changes_does_not_dirty_the_document"] notes = "name, tooltip, read only, required, locked; shared tooltip/flags are opt-in, mixed values preserved, failures keep the draft and roll back the batch (visibility pending)" [[feature]] @@ -4920,7 +4920,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::appearance_properties_restyle_every_widget", "crates/ui-egui/tests/prepare.rs::appearance_tab_restyles_the_field", "crates/automation/tests/automation.rs::tab_order_and_field_appearance_through_tools", "crates/forms/src/tests.rs::partial_appearance_keeps_custom_default_appearance_and_font_resources", "crates/forms/src/tests.rs::partial_appearance_preserves_each_widgets_indirect_colours_and_border_extensions", "crates/automation/tests/form_bulk.rs::bulk_properties_are_one_undo_step_preserve_other_looks_and_survive_save"] +evidence = ["crates/forms/src/tests.rs::appearance_properties_restyle_every_widget", "crates/ui-egui/tests/it/prepare.rs::appearance_tab_restyles_the_field", "crates/automation/tests/it/automation.rs::tab_order_and_field_appearance_through_tools", "crates/forms/src/tests.rs::partial_appearance_keeps_custom_default_appearance_and_font_resources", "crates/forms/src/tests.rs::partial_appearance_preserves_each_widgets_indirect_colours_and_border_extensions", "crates/automation/tests/it/form_bulk.rs::bulk_properties_are_one_undo_step_preserve_other_looks_and_survive_save"] notes = "border/fill colour, line width, solid/dashed/beveled/inset/underline, text colour, Helvetica/Times/Courier, font size; shared GUI and partial tool edits preserve untouched custom fonts, widget styles and unknown keys. Appearance regeneration still uses approximate metrics" [[feature]] @@ -4932,7 +4932,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field"] +evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/ui-egui/tests/it/prepare.rs::placing_moving_editing_and_deleting_a_field"] notes = "left/bottom/width/height in points; drag to move, corners to resize" [[feature]] @@ -4944,7 +4944,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"] +evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"] notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen, drawn in the font, size and color of the button's /DA (the form's /DR font, else Helvetica; an automatic size fits the button) and centered by Helvetica's widths; icon-only layout retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" [[feature]] @@ -4956,7 +4956,7 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_actions", "form_set_actions"] -evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/prepare.rs::actions_tab_adds_and_removes_actions"] +evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/it/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/it/prepare.rs::actions_tab_adds_and_removes_actions"] notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept." [[feature]] @@ -4968,7 +4968,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"] +evidence = ["crates/ui-egui/tests/it/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] notes = "None/Number/Percentage/Date/Time/Special with a live example; writes Acrobat's AF scripts" [[feature]] @@ -4980,7 +4980,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"] +evidence = ["crates/ui-egui/tests/it/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools"] notes = "range from/to" [[feature]] @@ -4992,7 +4992,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/tests.rs::renaming_a_field_renames_it_in_the_calculations_that_read_it", "crates/forms/src/tests.rs::a_rename_reaches_every_stored_form_of_a_calculation", "crates/automation/tests/automation.rs::renaming_a_field_keeps_the_formulas_that_read_it", "crates/ui-egui/tests/prepare.rs::renaming_a_field_says_how_many_calculations_now_use_the_new_name"] +evidence = ["crates/ui-egui/tests/it/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/tests.rs::renaming_a_field_renames_it_in_the_calculations_that_read_it", "crates/forms/src/tests.rs::a_rename_reaches_every_stored_form_of_a_calculation", "crates/automation/tests/it/automation.rs::renaming_a_field_keeps_the_formulas_that_read_it", "crates/ui-egui/tests/it/prepare.rs::renaming_a_field_says_how_many_calculations_now_use_the_new_name"] notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending. Renaming a field renames it in the calculations that read it (field lists, notation, and getField calls naming it in a string literal), in the rename's undo step; Field Properties says how many changed and form_set_props lists them. Not rewritten: names built at run time, other events' scripts, document-level scripts. Whether Acrobat does this is not observed." [[feature]] @@ -5028,7 +5028,7 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_set_props"] -evidence = ["crates/ui-egui/tests/prepare.rs::aligning_distributing_and_sizing_several_fields"] +evidence = ["crates/ui-egui/tests/it/prepare.rs::aligning_distributing_and_sizing_several_fields"] notes = "Shift/⌘-click to select several; right-click: Align (left, right, top, bottom, centres), Distribute, Set Fields to Same Size; the right-clicked field is the anchor" [[feature]] @@ -5040,7 +5040,7 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = [] -evidence = ["crates/forms/src/tests.rs::duplicating_a_field_across_pages_shares_its_value", "crates/ui-egui/tests/prepare.rs::duplicating_a_field_onto_every_page"] +evidence = ["crates/forms/src/tests.rs::duplicating_a_field_across_pages_shares_its_value", "crates/ui-egui/tests/it/prepare.rs::duplicating_a_field_onto_every_page"] notes = "right-click a field ▸ Duplicate: all pages or a range; one field, shared value" [[feature]] @@ -5055,8 +5055,8 @@ tools = ["form_add_field_table"] evidence = [ "crates/forms/src/table.rs::a_grid_fills_its_area_with_gaps_between_the_cells", "crates/engine/src/field_table.rs::a_table_is_added_and_undone_as_one_step", - "crates/automation/tests/automation.rs::adding_a_field_table_through_tools", - "crates/ui-egui/tests/prepare.rs::a_text_field_table_is_drawn_set_up_and_added_in_one_step", + "crates/automation/tests/it/automation.rs::adding_a_field_table_through_tools", + "crates/ui-egui/tests/it/prepare.rs::a_text_field_table_is_drawn_set_up_and_added_in_one_step", ] notes = "Prepare a form ▸ Text field table: drag an area, then set rows, columns, spacing, column widths (draggable dividers) and a name pattern per column in a movable window, with the table previewed on the page. Added as one undoable edit, or refused whole with the reason (a cell under 4 pt, a name with a period, a repeated or existing name); form_add_field_table does the same headlessly. Missing: copying an existing field of any type, with its properties, into a grid from its context menu, which is what this entry describes; the table only creates new text fields." @@ -5069,7 +5069,7 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::locked_fields_only_take_unlocking", "crates/ui-egui/tests/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties", "crates/forms/src/tests.rs::renaming_a_kid_is_checked_against_the_full_name_it_takes", "crates/forms/src/tests.rs::renaming_a_top_level_field_is_still_checked_against_top_level_names"] +evidence = ["crates/forms/src/tests.rs::locked_fields_only_take_unlocking", "crates/ui-egui/tests/it/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties", "crates/forms/src/tests.rs::renaming_a_kid_is_checked_against_the_full_name_it_takes", "crates/forms/src/tests.rs::renaming_a_top_level_field_is_still_checked_against_top_level_names"] notes = "rename in Field Properties ▸ General; Locked sets the widgets' annotation Locked flag (/F bit 8) and greys out the properties until unlocked; values can still be filled in" [[feature]] @@ -5081,7 +5081,7 @@ milestone = "M6" status = "shipped" commands = ["form.tab_order.row", "form.tab_order.column", "form.tab_order.structure"] tools = ["form_tab_order"] -evidence = ["crates/forms/src/tests.rs::tab_order_follows_the_page_setting", "crates/automation/tests/automation.rs::tab_order_and_field_appearance_through_tools", "crates/forms/src/tests.rs::ordering_tabs_manually", "crates/ui-egui/tests/prepare.rs::the_fields_panel_orders_tabs_manually"] +evidence = ["crates/forms/src/tests.rs::tab_order_follows_the_page_setting", "crates/automation/tests/it/automation.rs::tab_order_and_field_appearance_through_tools", "crates/forms/src/tests.rs::ordering_tabs_manually", "crates/ui-egui/tests/it/prepare.rs::the_fields_panel_orders_tabs_manually"] notes = "rows, columns, structure/annotation order (page /Tabs), and manual order from the Fields panel while preparing a form; honoured by Tab" [[feature]] @@ -5105,7 +5105,7 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = [] -evidence = ["crates/ui-egui/tests/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties"] +evidence = ["crates/ui-egui/tests/it/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties"] notes = "Preview / Edit fields toggle in the Prepare a form panel" [[feature]] @@ -5117,7 +5117,7 @@ milestone = "M6" status = "partial" commands = ["form.detect", "form.prepare"] tools = ["form_detect_fields"] -evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/forms/src/tests.rs::page_shapes_reads_rectangles_with_points_along_their_sides", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/engine/src/tests.rs::detected_fields_have_no_border_or_fill", "crates/forms/src/tests.rs::detection_makes_a_row_of_touching_cells_one_comb_field", "crates/engine/src/tests.rs::detecting_comb_boxes_adds_one_comb_field_per_row", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/automation/tests/automation.rs::detecting_comb_boxes_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] +evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/forms/src/tests.rs::page_shapes_reads_rectangles_with_points_along_their_sides", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/engine/src/tests.rs::detected_fields_have_no_border_or_fill", "crates/forms/src/tests.rs::detection_makes_a_row_of_touching_cells_one_comb_field", "crates/engine/src/tests.rs::detecting_comb_boxes_adds_one_comb_field_per_row", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/it/automation.rs::detecting_form_fields_through_tools", "crates/automation/tests/it/automation.rs::detecting_comb_boxes_through_tools", "crates/ui-egui/tests/it/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/it/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] notes = "Underscore runs, rules, empty boxes, small squares (check boxes) and rows of two or more touching cells of one size (one comb text field, character limit = cells; tested on drawn forms only); a box drawn with points along its sides (a midpoint on each vertical side, as the French 2042 form's cells) is read as the rectangle it is (#2942); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Detected fields get no border and no fill, since the page already draws the line or box they sit on; fields drawn by hand keep the grey border and white fill. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: dotted leader lines, tables of cells as such, deskewing, radio groups." [[feature]] @@ -5129,7 +5129,7 @@ milestone = "M6" status = "shipped" commands = ["form.detect"] tools = ["form_detect_fields"] -evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools"] +evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/automation/tests/it/automation.rs::detecting_form_fields_through_tools"] notes = "Names come from the label to the left (or above; right of check boxes), unique like Acrobat's (Name2, Text1)." [[feature]] @@ -5141,7 +5141,7 @@ milestone = "M6" status = "shipped" commands = ["tools.document_js"] tools = ["js_document_scripts", "js_set_document_script"] -evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/automation/tests/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats"] +evidence = ["crates/ui-egui/tests/it/javascript.rs::document_scripts_and_preferences", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats"] notes = "Names /JavaScript tree: list, add, edit, delete; functions are available to field scripts." [[feature]] @@ -5165,7 +5165,7 @@ milestone = "M6" status = "partial" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"] +evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools"] notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet." [[feature]] @@ -5177,7 +5177,7 @@ milestone = "M6" status = "shipped" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/ui-egui/tests/javascript.rs::console_runs_scripts_against_the_document", "crates/automation/tests/automation.rs::javascript_through_tools"] +evidence = ["crates/ui-egui/tests/it/javascript.rs::console_runs_scripts_against_the_document", "crates/automation/tests/it/automation.rs::javascript_through_tools"] notes = "\u2318J: run JavaScript against the document, with console output, results and errors." [[feature]] @@ -5213,7 +5213,7 @@ milestone = "M6" status = "shipped" commands = ["app.preferences"] tools = ["js_enabled"] -evidence = ["crates/js/src/tests.rs::the_sandbox_has_no_host_access", "crates/js/src/tests.rs::errors_and_runaway_scripts_are_contained", "crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/document_links.rs::a_script_asks_first_and_only_once", "crates/ui-egui/tests/document_links.rs::a_script_cannot_open_other_kinds_of_address"] +evidence = ["crates/js/src/tests.rs::the_sandbox_has_no_host_access", "crates/js/src/tests.rs::errors_and_runaway_scripts_are_contained", "crates/ui-egui/tests/it/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/it/document_links.rs::a_script_asks_first_and_only_once", "crates/ui-egui/tests/it/document_links.rs::a_script_cannot_open_other_kinds_of_address"] notes = "Preferences \u25b8 JavaScript \u25b8 Enable Acrobat JavaScript (remembered); no host access; loop and recursion limits; app.launchURL asks before opening and only offers http, https and mailto addresses (#90, #91)." [[feature]] @@ -5237,7 +5237,7 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "form_fill"] -evidence = ["crates/engine/src/tests.rs::static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back", "crates/ui-egui/tests/ui_group/xfa.rs::a_static_xfa_form_is_filled_from_its_data_and_says_so", "crates/xfa/src/tests.rs::static_forms_read_their_values_from_the_datasets", "crates/xfa/src/tests.rs::writing_values_keeps_the_data_no_field_binds_to", "crates/xfa/src/tests.rs::utf16_packets_are_read_and_written_back_as_utf16", "crates/engine/src/tests.rs::xfa_data_no_field_binds_to_survives_an_edit_and_save"] +evidence = ["crates/engine/src/tests.rs::static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back", "crates/ui-egui/tests/it/xfa.rs::a_static_xfa_form_is_filled_from_its_data_and_says_so", "crates/xfa/src/tests.rs::static_forms_read_their_values_from_the_datasets", "crates/xfa/src/tests.rs::writing_values_keeps_the_data_no_field_binds_to", "crates/xfa/src/tests.rs::utf16_packets_are_read_and_written_back_as_utf16", "crates/engine/src/tests.rs::xfa_data_no_field_binds_to_survives_an_edit_and_save"] notes = "the AcroForm fields are shown and filled as in any form; on open their values come from the XFA datasets (what another viewer saved) by the fields' Designer names (recorded as a document warning), and every edit merges the values back into the datasets, keeping data no field binds to, so Acrobat shows the same values; values that can't be written are warnings. Missing: no template semantics (no scripts, calculations or hidden subforms), explicit bind refs are not followed (#60)" [[feature]] @@ -5249,7 +5249,7 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn"] +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn"] notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] @@ -5273,7 +5273,7 @@ milestone = "M12" status = "partial" commands = ["file.open", "file.save", "form.export_data", "form.import_data"] tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"] -evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/automation.rs::xfa_data_files_export_and_import_through_tools"] +evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools"] notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" [[feature]] @@ -5285,7 +5285,7 @@ milestone = "M12" status = "shipped" commands = ["form.flatten"] tools = ["doc_flatten", "xfa_to_static"] -evidence = ["crates/xfa/src/tests.rs::stripping_takes_the_xfa_out_and_leaves_an_ordinary_document", "crates/engine/src/tests.rs::flattening_an_xfa_form_bakes_its_pages_and_takes_the_xfa_out", "crates/engine/src/tests.rs::an_xfa_form_converts_to_a_static_form_that_keeps_its_fields", "crates/automation/tests/automation.rs::xfa_forms_flatten_and_convert_to_static_through_tools"] +evidence = ["crates/xfa/src/tests.rs::stripping_takes_the_xfa_out_and_leaves_an_ordinary_document", "crates/engine/src/tests.rs::flattening_an_xfa_form_bakes_its_pages_and_takes_the_xfa_out", "crates/engine/src/tests.rs::an_xfa_form_converts_to_a_static_form_that_keeps_its_fields", "crates/automation/tests/it/automation.rs::xfa_forms_flatten_and_convert_to_static_through_tools"] notes = "Flatten form fields on a laid-out XFA form bakes the fields into the pages and takes the XFA out (packets, /NeedsRendering, PdfCraft's private keys), so every viewer, Adobe's included, shows the baked pages rather than laying the form out again from the template. xfa_to_static (tool and engine edit) keeps the laid-out pages and the fields with their values as an ordinary AcroForm whose scripts no longer run. Both undoable until saved. Missing: a menu entry for the conversion (the tool and the MCP server have it); flattening keeps the layout as PdfCraft laid it out, not as Acrobat would (form.xfa-dynamic)" [[feature]] @@ -5297,7 +5297,7 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages"] +evidence = ["crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages"] notes = "typewriter FreeText, 10 pt Helvetica. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet" [[feature]] @@ -5309,7 +5309,7 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.check", "sign.fill.cross", "sign.fill.dot", "sign.fill.line"] tools = ["fill_sign_add"] -evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn"] +evidence = ["crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn"] notes = "PdfCraft-drawn stamps" [[feature]] @@ -5321,7 +5321,7 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.date"] tools = ["fill_sign_add", "fill_sign_date_format"] -evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/date_format.rs::fill_and_sign_dates_follow_the_date_format_preference_and_it_is_remembered", "crates/engine/src/dates.rs::presets_write_the_day_in_their_own_order", "crates/engine/src/dates.rs::names_follow_the_date_language", "crates/ui-egui/tests/date_format.rs::month_names_follow_the_date_language_or_else_the_interface_language", "crates/ui-egui/tests/date_format.rs::a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first"] +evidence = ["crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/it/date_format.rs::fill_and_sign_dates_follow_the_date_format_preference_and_it_is_remembered", "crates/engine/src/dates.rs::presets_write_the_day_in_their_own_order", "crates/engine/src/dates.rs::names_follow_the_date_language", "crates/ui-egui/tests/it/date_format.rs::month_names_follow_the_date_language_or_else_the_interface_language", "crates/ui-egui/tests/it/date_format.rs::a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first"] notes = "local date in Preferences ▸ Date format (default m/d/yyyy; 14 presets, year-first slash forms included, or any pattern in Acrobat date letters with \\ escapes, checked and previewed as typed, remembered; only whole date codes accepted; month and weekday names in the interface languages, Czech months in the genitive after a day, following the interface language unless one is picked; fill_sign_add takes a one-off format and language). Beyond Acrobat: its Fill & Sign has no format setting (only Acrobat Sign accounts do). Missing: Fill & Sign text is Helvetica/WinAnsi, so a date with letters outside Western European (Czech ř, Japanese, Chinese names) is refused with a warning instead of being saved as ?; the OS locale's format isn't offered as a preset; names in languages without an interface translation" [[feature]] @@ -5333,7 +5333,7 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"] tools = ["fill_sign_add", "comment_edit", "comment_image_preview"] -evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour", "crates/ui-egui/tests/fill_sign.rs::undo_button_and_ctrl_z_take_back_one_drawn_stroke_at_a_time", "crates/ui-egui/src/fill_sign.rs::undo_removes_the_last_stroke_and_skips_empty_ones", "crates/ui-egui/src/comments.rs::shift_keeps_the_current_proportions_unless_the_box_has_its_own"] +evidence = ["crates/ui-egui/tests/it/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/it/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/it/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/it/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/it/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/it/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/it/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/it/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/it/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/it/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/it/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/it/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/it/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/it/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/it/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/it/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/it/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/it/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/it/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/it/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/it/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour", "crates/ui-egui/tests/it/fill_sign.rs::undo_button_and_ctrl_z_take_back_one_drawn_stroke_at_a_time", "crates/ui-egui/src/fill_sign.rs::undo_removes_the_last_stroke_and_skips_empty_ones", "crates/ui-egui/src/comments.rs::shift_keeps_the_current_proportions_unless_the_box_has_its_own"] notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn (an Undo button and Ctrl/Cmd+Z in the pad take back the last stroke; Clear empties it) or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; holding Shift on a corner handle keeps any resizable comment's current proportions (#2199); native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)" [[feature]] @@ -5345,7 +5345,7 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_save", "doc_flatten"] -evidence = ["crates/edit/src/tests.rs::fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0", "crates/engine/src/tests.rs::flattening_fill_and_sign_keeps_other_comments_fields_and_undoes", "crates/engine/src/tests.rs::flattening_fill_and_sign_is_refused_without_permission_to_change", "crates/automation/tests/automation.rs::saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments", "crates/ui-egui/tests/fill_sign.rs::saving_flattens_fill_and_sign_only_when_the_preference_is_on"] +evidence = ["crates/edit/src/tests.rs::fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0", "crates/engine/src/tests.rs::flattening_fill_and_sign_keeps_other_comments_fields_and_undoes", "crates/engine/src/tests.rs::flattening_fill_and_sign_is_refused_without_permission_to_change", "crates/automation/tests/it/automation.rs::saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments", "crates/ui-egui/tests/it/fill_sign.rs::saving_flattens_fill_and_sign_only_when_the_preference_is_on"] notes = "Preferences ▸ Fill & Sign, and doc_save flatten_fill_sign, bake text, marks and signatures into the page. The option defaults off, so a normal save leaves them editable. Other comments and form fields stay. Flatten comments still flattens every comment." [[feature]] @@ -5397,7 +5397,7 @@ milestone = "M3" status = "shipped" commands = ["protect.properties"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/editing.rs::password_prompt_opens_and_security_tab_reports_the_details"] +evidence = ["crates/ui-egui/tests/it/editing.rs::password_prompt_opens_and_security_tab_reports_the_details"] notes = "" [[feature]] @@ -5409,7 +5409,7 @@ milestone = "M8" status = "shipped" commands = ["protect.password"] tools = ["doc_protect"] -evidence = ["crates/ui-egui/tests/ui_group/protect.rs::protecting_for_viewing_requires_matching_passwords_and_saves_encrypted", "crates/engine/src/tests.rs::protect_with_an_open_password_then_save_reopen_and_undo", "crates/automation/tests/automation.rs::protecting_through_tools"] +evidence = ["crates/ui-egui/tests/it/protect.rs::protecting_for_viewing_requires_matching_passwords_and_saves_encrypted", "crates/engine/src/tests.rs::protect_with_an_open_password_then_save_reopen_and_undo", "crates/automation/tests/it/automation.rs::protecting_through_tools"] notes = "Protect Using Password (Viewing); applied by the next save" [[feature]] @@ -5421,7 +5421,7 @@ milestone = "M8" status = "shipped" commands = ["protect.password"] tools = ["doc_protect"] -evidence = ["crates/ui-egui/tests/ui_group/protect.rs::editing_restrictions_via_advanced_options", "crates/engine/src/tests.rs::permissions_password_restricts_others_but_not_this_session", "crates/automation/tests/automation.rs::protecting_through_tools"] +evidence = ["crates/ui-egui/tests/it/protect.rs::editing_restrictions_via_advanced_options", "crates/engine/src/tests.rs::permissions_password_restricts_others_but_not_this_session", "crates/automation/tests/it/automation.rs::protecting_through_tools"] notes = "Protect Using Password (Editing); the author keeps full rights after saving" [[feature]] @@ -5433,7 +5433,7 @@ milestone = "M8" status = "shipped" commands = ["protect.password"] tools = ["doc_protect"] -evidence = ["crates/ui-egui/tests/ui_group/protect.rs::editing_restrictions_via_advanced_options", "crates/engine/src/tests.rs::permissions_password_restricts_others_but_not_this_session"] +evidence = ["crates/ui-egui/tests/it/protect.rs::editing_restrictions_via_advanced_options", "crates/engine/src/tests.rs::permissions_password_restricts_others_but_not_this_session"] notes = "Advanced Options: printing (none/low/high), changes (5 levels), copying, screen readers" [[feature]] @@ -5481,7 +5481,7 @@ milestone = "M8" status = "shipped" commands = ["protect.password"] tools = [] -evidence = ["crates/ui-egui/tests/ui_group/protect.rs::protecting_for_viewing_requires_matching_passwords_and_saves_encrypted"] +evidence = ["crates/ui-egui/tests/it/protect.rs::protecting_for_viewing_requires_matching_passwords_and_saves_encrypted"] notes = "Weak / Medium / Strong under the password field" [[feature]] @@ -5493,7 +5493,7 @@ milestone = "M8" status = "shipped" commands = ["protect.remove"] tools = ["doc_unprotect"] -evidence = ["crates/engine/src/tests.rs::protect_with_an_open_password_then_save_reopen_and_undo", "crates/automation/tests/automation.rs::protecting_through_tools"] +evidence = ["crates/engine/src/tests.rs::protect_with_an_open_password_then_save_reopen_and_undo", "crates/automation/tests/it/automation.rs::protecting_through_tools"] notes = "needs the permissions password (or none set)" [[feature]] @@ -5577,7 +5577,7 @@ milestone = "M8" status = "shipped" commands = ["redact.mark"] tools = ["redact_mark"] -evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/automation/tests/automation.rs::redacting_through_tools", "crates/redact/src/tests.rs::glyphs_under_a_mark_go_and_the_rest_stays_put"] +evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/redact/src/tests.rs::glyphs_under_a_mark_go_and_the_rest_stays_put"] notes = "drag across text (or select first) with the Redact tool" [[feature]] @@ -5589,7 +5589,7 @@ milestone = "M8" status = "shipped" commands = ["redact.mark"] tools = ["redact_mark"] -evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared"] +evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared"] notes = "drag a box anywhere else" [[feature]] @@ -5601,7 +5601,7 @@ milestone = "M8" status = "shipped" commands = ["redact.pages"] tools = ["redact_mark"] -evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"] +evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing"] notes = "Mark Page Range: current page or a range" [[feature]] @@ -5613,7 +5613,7 @@ milestone = "M8" status = "shipped" commands = ["redact.search"] tools = ["redact_mark"] -evidence = ["crates/automation/tests/automation.rs::redacting_through_tools"] +evidence = ["crates/automation/tests/it/automation.rs::redacting_through_tools"] notes = "single word or phrase, every match on every page" [[feature]] @@ -5625,7 +5625,7 @@ milestone = "M8" status = "shipped" commands = ["redact.search"] tools = ["redact_mark"] -evidence = ["crates/redact/src/patterns.rs::word_lists_are_trimmed_unique_and_bounded", "crates/automation/tests/automation.rs::redacting_word_lists_through_tools", "crates/automation/tests/automation.rs::redaction_search_respects_case_and_word_boundaries", "crates/ui-egui/tests/ui_group/redact.rs::redacting_an_imported_word_list", "crates/ui-egui/tests/ui_group/redact.rs::redaction_search_can_match_case_and_whole_words"] +evidence = ["crates/redact/src/patterns.rs::word_lists_are_trimmed_unique_and_bounded", "crates/automation/tests/it/automation.rs::redacting_word_lists_through_tools", "crates/automation/tests/it/automation.rs::redaction_search_respects_case_and_word_boundaries", "crates/ui-egui/tests/it/redact.rs::redacting_an_imported_word_list", "crates/ui-egui/tests/it/redact.rs::redaction_search_can_match_case_and_whole_words"] notes = "Find text and redact ▸ Multiple words or phrases: one per line, typed or imported from a .txt file (at most 1 MB, 1,000 entries of 256 characters); every match of each is marked in one undo step; redact_mark words reports marks per word. Single phrase and word-list searches offer case-sensitive and whole-words-only options in the UI and headless tool. Not yet: exporting the list" [[feature]] @@ -5637,7 +5637,7 @@ milestone = "M8" status = "shipped" commands = ["redact.search"] tools = ["redact_mark"] -evidence = ["crates/redact/src/patterns.rs::phone_numbers", "crates/redact/src/patterns.rs::slashed_and_labelled_unseparated_phone_numbers", "crates/redact/src/patterns.rs::credit_cards_need_a_valid_checksum", "crates/automation/tests/automation.rs::redacting_through_tools"] +evidence = ["crates/redact/src/patterns.rs::phone_numbers", "crates/redact/src/patterns.rs::slashed_and_labelled_unseparated_phone_numbers", "crates/redact/src/patterns.rs::credit_cards_need_a_valid_checksum", "crates/automation/tests/it/automation.rs::redacting_through_tools"] notes = "phone (North American, E.164 with + country code, national with trunk 0, a slash after a trunk-0 area code; numbers without separators only after a phone label such as Tel. or cell.), e-mail, credit card (Luhn), SSN, dates (English US forms); text drawn in a font whose built-in encoding we hold no table for (Symbol, ZapfDingbats, MacExpert) has no Unicode mapping, so a pattern search cannot see it -- it is skipped rather than mis-decoded" [[feature]] @@ -5685,7 +5685,7 @@ milestone = "M8" status = "shipped" commands = ["redact.properties"] tools = ["redact_mark"] -evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/ui-egui/tests/ui_group/redact.rs::redaction_properties_survive_a_restart", "crates/ui-egui/tests/ui_group/redact.rs::redaction_properties_for_this_run_only_and_cancel", "crates/redact/src/tests.rs::overlay_text_takes_its_font_size_colour_alignment_and_repeats"] +evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/ui-egui/tests/it/redact.rs::redaction_properties_survive_a_restart", "crates/ui-egui/tests/it/redact.rs::redaction_properties_for_this_run_only_and_cancel", "crates/redact/src/tests.rs::overlay_text_takes_its_font_size_colour_alignment_and_repeats"] notes = "Acrobat's three groups: redacted area fill colour or none; overlay text with font, size or auto-size, colour, alignment and repeat, or redaction codes (protect.redaction-codes); Redaction mark appearance: outline colour (/C), fill colour shown while the pointer is over a mark (a preference, not written), opacity (/CA). Make properties default: ticked, OK keeps the values for the next run (settings \"redact_prefs\"); unticked, this run only and the saved defaults stay; Cancel puts back what the dialog opened with." [[feature]] @@ -5697,7 +5697,7 @@ milestone = "M8" status = "partial" commands = ["redact.properties"] tools = ["redact_mark"] -evidence = ["crates/redact/src/tests.rs::redaction_codes_join_in_set_order_and_reject_strangers", "crates/automation/tests/automation.rs::redacting_with_codes_through_tools", "crates/ui-egui/tests/ui_group/redact.rs::redaction_codes_become_the_overlay_text"] +evidence = ["crates/redact/src/tests.rs::redaction_codes_join_in_set_order_and_reject_strangers", "crates/automation/tests/it/automation.rs::redacting_with_codes_through_tools", "crates/ui-egui/tests/it/redact.rs::redaction_codes_become_the_overlay_text"] notes = "built-in U.S. FOIA (5 U.S.C. 552(b)) and U.S. Privacy Act (552a) sets; picked codes become the overlay text, in the set's order. Missing: Edit Codes, custom code sets, import/export of code sets" [[feature]] @@ -5709,7 +5709,7 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::redaction_is_refused_when_the_page_still_shows_text_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"] +evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::redaction_is_refused_when_the_page_still_shows_text_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"] notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text whose font is set through an ExtGState or taken by a form from its caller, some predefined CMaps and rotated Type 3 fonts are not removed yet, so those documents cannot be redacted. Removed images and forms are unlinked from page-tree resources (own, inherited or shared), not yet from a form's own resources, annotation appearances or patterns" [[feature]] @@ -5757,7 +5757,7 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/redact/src/tests.rs::replies_to_a_redacted_comment_go_with_it", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"] +evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/redact/src/tests.rs::replies_to_a_redacted_comment_go_with_it", "crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing"] notes = "with pop-ups and replies (/IRT, transitively; replies on other pages are not followed); fields lose all widgets" [[feature]] @@ -5817,7 +5817,7 @@ milestone = "M8" status = "shipped" commands = ["protect.remove_hidden"] tools = ["doc_hidden_info", "doc_remove_hidden"] -evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools", "crates/redact/src/tests.rs::removed_links_and_comments_leave_the_structure_tree_and_the_saved_file"] +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/it/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/it/automation.rs::removing_hidden_information_through_tools", "crates/redact/src/tests.rs::removed_links_and_comments_leave_the_structure_tree_and_the_saved_file"] notes = "metadata, attachments, comments, form fields (flattened), hidden text, hidden layers, bookmarks, links/actions/scripts (with the structure tree's references to removed links and comments, #2447), private data; overlapping objects and cropped content pending" [[feature]] @@ -5829,7 +5829,7 @@ milestone = "M8" status = "shipped" commands = ["redact.sanitize"] tools = ["doc_remove_hidden"] -evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools"] +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/it/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/it/automation.rs::removing_hidden_information_through_tools"] notes = "every category, then a full rewrite on save" [[feature]] @@ -5841,7 +5841,7 @@ milestone = "M8" status = "partial" commands = [] tools = ["doc_audit_redactions"] -evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"] +evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "crates/automation/tests/it/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/it/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/it/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"] notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." [[feature]] @@ -5917,7 +5917,7 @@ milestone = "M9" status = "shipped" commands = ["sign.digital"] tools = ["sign_document"] -evidence = ["crates/sign/tests/crypto.rs::opens_every_openssl_flavour_of_pkcs12", "crates/sign/tests/crypto.rs::opens_pkcs12_as_windows_writes_it", "crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +evidence = ["crates/sign/tests/it/crypto.rs::opens_every_openssl_flavour_of_pkcs12", "crates/sign/tests/it/crypto.rs::opens_pkcs12_as_windows_writes_it", "crates/sign/tests/it/pdf.rs::signing_then_validating_with_and_without_trust", "crates/ui-egui/tests/it/signing.rs::certifying_without_a_visible_signature", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] notes = "PBES2 (AES, 3DES) and legacy SHA-1 3DES/RC2 files; RSA, P-256, P-384; both the DER OpenSSL writes and the BER Windows writes (indefinite lengths, segmented AuthenticatedSafe)" [[feature]] @@ -5929,7 +5929,7 @@ milestone = "M9" status = "shipped" commands = ["sign.digital"] tools = ["sign_keychain_ids", "sign_document"] -evidence = ["crates/sign/tests/pdf.rs::signing_with_keychain_identities", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +evidence = ["crates/sign/tests/it/pdf.rs::signing_with_keychain_identities", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] notes = "Security framework identities (RSA PKCS #1 v1.5, ECDSA P-256/P-384) listed with the digital IDs in Sign with a Digital ID; the key never leaves the Keychain (ExternalKey); keychain: for agents. The test uses a throwaway keychain file (run with --ignored)" [[feature]] @@ -5941,7 +5941,7 @@ milestone = "M9" status = "shipped" commands = ["sign.digital", "sign.certify"] tools = ["sign_windows_ids", "sign_document"] -evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password", "crates/ui-egui/tests/signing.rs::an_unusable_id_is_shown_with_its_reason_and_cannot_be_chosen", "crates/sign/tests/chain.rs::a_smart_cards_certificates_that_may_not_sign_say_why_in_id_lists"] +evidence = ["crates/sign/tests/it/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/it/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/it/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/it/signing.rs::windows_store_identity_does_not_ask_for_a_file_password", "crates/ui-egui/tests/it/signing.rs::an_unusable_id_is_shown_with_its_reason_and_cannot_be_chosen", "crates/sign/tests/it/chain.rs::a_smart_cards_certificates_that_may_not_sign_say_why_in_id_lists"] notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows: for agents and Windows store labels in the desktop ID picker. The signature embeds the issuers Windows chains the certificate to. Store certificates that can't sign (no private key, another key type, a key CNG can't open, or a key usage or extended key usage that doesn't allow signing documents, like a CAC's encryption and logon certificates) are reported with the reason and Windows error code (greyed in the picker, `unusable` in sign_windows_ids) rather than left out; keys that need a prompt to open are listed and prompt at signing. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates and a key-less certificate (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); still missing: the Local Machine store, legacy CryptoAPI-only providers, P-521/Brainpool/Ed25519 keys, direct PKCS #11." [[feature]] @@ -5953,7 +5953,7 @@ milestone = "M9" status = "shipped" commands = ["sign.digital", "sign.certify"] tools = ["sign_document"] -evidence = ["crates/sign/tests/pdf.rs::encrypted_documents_are_signed_with_an_unencrypted_contents", "crates/sign/tests/pdf.rs::encrypted_documents_are_signed_only_as_their_permissions_allow", "crates/sign/tests/pdf.rs::encrypted_documents_take_a_document_timestamp", "crates/cos/tests/encryption.rs::signature_contents_stay_unencrypted", "crates/automation/tests/automation.rs::signing_an_encrypted_form_through_tools"] +evidence = ["crates/sign/tests/it/pdf.rs::encrypted_documents_are_signed_with_an_unencrypted_contents", "crates/sign/tests/it/pdf.rs::encrypted_documents_are_signed_only_as_their_permissions_allow", "crates/sign/tests/it/pdf.rs::encrypted_documents_take_a_document_timestamp", "crates/cos/tests/it/encryption.rs::signature_contents_stay_unencrypted", "crates/automation/tests/it/automation.rs::signing_an_encrypted_form_through_tools"] notes = "RC4-40/128, AES-128 and AES-256 Standard security; the incremental update keeps /Encrypt and /ID, encrypts the new strings and streams, and leaves the signature /Contents unencrypted (§7.6.2), which reading also honours. Opened with the user password, permissions (Table 22) apply: existing signature fields need fill-forms, new fields need annotate + modify, certifying needs modify, a document timestamp needs fill-forms; the owner may do anything. Not yet: certificate (public-key) security, which PdfCraft can't open; checked only against PdfCraft's own validator, not yet against an external one (pyHanko) or Acrobat" [[feature]] @@ -5977,7 +5977,7 @@ milestone = "M9" status = "shipped" commands = ["sign.digital"] tools = ["sign_id_create"] -evidence = ["crates/sign/tests/crypto.rs::new_digital_ids_are_self_signed_and_survive_a_p12_round_trip", "crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +evidence = ["crates/sign/tests/it/crypto.rs::new_digital_ids_are_self_signed_and_survive_a_p12_round_trip", "crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] notes = "RSA 2048/3072/4096 or P-256, saved as a password-protected .p12" [[feature]] @@ -5989,7 +5989,7 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = [] -evidence = ["crates/sign/tests/crypto.rs::certificates_load_from_pem_and_der_and_export_as_pem"] +evidence = ["crates/sign/tests/it/crypto.rs::certificates_load_from_pem_and_der_and_export_as_pem"] notes = "Signatures panel ▸ Export certificate (.cer, PEM)" [[feature]] @@ -6001,7 +6001,7 @@ milestone = "M9" status = "shipped" commands = ["sign.digital"] tools = ["sign_document"] -evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] notes = "drag a rectangle; Acrobat's name-and-details appearance" [[feature]] @@ -6013,7 +6013,7 @@ milestone = "M9" status = "shipped" commands = ["sign.digital"] tools = ["sign_document"] -evidence = ["crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/ui-egui/tests/signing.rs::clicking_an_empty_signature_field_signs_it"] +evidence = ["crates/sign/tests/it/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/ui-egui/tests/it/signing.rs::clicking_an_empty_signature_field_signs_it"] notes = "" [[feature]] @@ -6025,7 +6025,7 @@ milestone = "M9" status = "shipped" commands = ["sign.certify_invisible"] tools = ["sign_document"] -evidence = ["crates/sign/tests/pdf.rs::invisible_signatures_and_refusals", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature"] +evidence = ["crates/sign/tests/it/pdf.rs::invisible_signatures_and_refusals", "crates/ui-egui/tests/it/signing.rs::certifying_without_a_visible_signature"] notes = "" [[feature]] @@ -6037,7 +6037,7 @@ milestone = "M9" status = "partial" commands = ["sign.digital"] tools = ["sign_document"] -evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature", "crates/sign/tests/pdf.rs::visible_signatures_show_a_drawn_typed_or_image_graphic", "crates/sign/tests/pdf.rs::malformed_signature_graphics_are_refused_not_drawn", "crates/sign/tests/pdf.rs::an_encrypted_documents_signature_graphic_is_encrypted_with_it", "crates/automation/tests/automation.rs::signing_with_an_image_typed_or_drawn_graphic_through_tools", "crates/sign/tests/pdf.rs::appearance_labels_helvetica_cannot_draw_are_refused", "crates/ui-egui/tests/signing.rs::the_signature_labels_follow_the_interface_language", "crates/automation/tests/automation.rs::signing_with_translated_labels_through_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::the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature", "crates/sign/tests/it/pdf.rs::visible_signatures_show_a_drawn_typed_or_image_graphic", "crates/sign/tests/it/pdf.rs::malformed_signature_graphics_are_refused_not_drawn", "crates/sign/tests/it/pdf.rs::an_encrypted_documents_signature_graphic_is_encrypted_with_it", "crates/automation/tests/it/automation.rs::signing_with_an_image_typed_or_drawn_graphic_through_tools", "crates/sign/tests/it/pdf.rs::appearance_labels_helvetica_cannot_draw_are_refused", "crates/ui-egui/tests/it/signing.rs::the_signature_labels_follow_the_interface_language", "crates/automation/tests/it/automation.rs::signing_with_translated_labels_through_tools"] notes = "text items (name, date, reason, location, DN, labels), and a graphic where the large name would be: the saved Fill & Sign signature (drawn, typed or image) or an imported PNG/JPEG (white paper made transparent, as in Fill & Sign), remembered for the next signing; alone it fills the box. The labels are stamped in the interface language when Helvetica (WinAnsi) can draw all of them (German, Spanish, French, Italian, Portuguese, Polish, Turkish, Slovene, Hungarian), otherwise all in English with a note under the preview, which shows the same labels and the signing time; sign_document takes labels and the shown items (appearance), and a drawn label the font can't draw is refused, never written as ?. Missing: labels in non-Latin scripts (needs an embedded font), the DN line in the preview, named saved appearances (one remembered choice only), a PDF page as the graphic, the logo/watermark background, drawing a new signature inside the dialog" [[feature]] @@ -6049,7 +6049,7 @@ milestone = "M9" status = "shipped" commands = ["sign.digital"] tools = ["sign_document"] -evidence = ["crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +evidence = ["crates/sign/tests/it/pdf.rs::signing_then_validating_with_and_without_trust", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] notes = "" [[feature]] @@ -6073,7 +6073,7 @@ milestone = "M9" status = "shipped" commands = ["sign.validate"] tools = ["sign_list"] -evidence = ["crates/sign/tests/pdf.rs::validates_a_signature_made_by_openssl", "crates/sign/tests/pdf.rs::validates_signatures_written_with_ber_indefinite_lengths", "crates/sign/tests/ber.rs::deeply_nested_segmented_content_is_an_error_not_a_stack_overflow", "crates/sign/tests/ber.rs::indefinite_length_nesting_bombs_are_refused"] +evidence = ["crates/sign/tests/it/pdf.rs::validates_a_signature_made_by_openssl", "crates/sign/tests/it/pdf.rs::validates_signatures_written_with_ber_indefinite_lengths", "crates/sign/tests/it/ber.rs::deeply_nested_segmented_content_is_an_error_not_a_stack_overflow", "crates/sign/tests/it/ber.rs::indefinite_length_nesting_bombs_are_refused"] notes = "validated, including BER-encoded CMS (indefinite lengths and segmented strings, as DocuSign, Documenso, Adobe PPKMS and Windows write it; the BER is reported in the details); new signatures use CAdES. Not read: BER high tag numbers. Still missing: multiple SignerInfos, counter-signatures, CMS with the signer's cert referenced but absent" [[feature]] @@ -6085,7 +6085,7 @@ milestone = "M9" status = "shipped" commands = ["sign.digital"] tools = ["sign_document"] -evidence = ["crates/sign/tests/crypto.rs::signatures_round_trip_for_every_key_type", "crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust"] +evidence = ["crates/sign/tests/it/crypto.rs::signatures_round_trip_for_every_key_type", "crates/sign/tests/it/pdf.rs::signing_then_validating_with_and_without_trust"] notes = "ETSI.CAdES.detached with signing-certificate-v2; pdfsig reports them valid" [[feature]] @@ -6097,7 +6097,7 @@ milestone = "M9" status = "shipped" commands = ["sign.digital"] tools = ["sign_document"] -evidence = ["crates/sign/tests/crypto.rs::signatures_round_trip_for_every_key_type", "crates/sign/tests/crypto.rs::rsa_signatures_are_read_with_every_digestinfo_variant", "crates/sign/tests/crypto.rs::ecdsa_signatures_are_read_as_der_non_minimal_der_or_raw", "crates/sign/tests/crypto.rs::verifies_ecdsa_on_p521_and_the_brainpool_curves", "crates/sign/tests/crypto.rs::keys_of_unknown_algorithms_are_unsupported_not_wrong", "crates/sign/tests/crypto.rs::tolerated_encodings_are_noted_and_standard_ones_are_not", "crates/sign/tests/crypto.rs::weak_digests_say_so_in_words_that_are_pinned", "crates/sign/tests/crypto.rs::an_rsa_signature_value_not_below_the_modulus_is_refused", "crates/sign/tests/crypto.rs::hostile_rsa_signature_values_are_wrong_not_crashes", "crates/sign/tests/algorithms.rs::rsa_with_sha3_sha512_256_and_ripemd160", "crates/sign/tests/algorithms.rs::rsa_pss_with_its_declared_parameters", "crates/sign/tests/algorithms.rs::ed25519_signs_the_signed_attributes_themselves", "crates/sign/tests/algorithms.rs::ecdsa_on_brainpool_p512r1", "crates/sign/tests/algorithms.rs::brainpool_p512r1_keys_must_have_coordinates_reduced_modulo_p", "crates/sign/tests/algorithms.rs::only_weak_digests_leave_a_note_when_a_signature_validates", "crates/sign/tests/pdf.rs::validates_the_legacy_adbe_x509_rsa_sha1_format"] +evidence = ["crates/sign/tests/it/crypto.rs::signatures_round_trip_for_every_key_type", "crates/sign/tests/it/crypto.rs::rsa_signatures_are_read_with_every_digestinfo_variant", "crates/sign/tests/it/crypto.rs::ecdsa_signatures_are_read_as_der_non_minimal_der_or_raw", "crates/sign/tests/it/crypto.rs::verifies_ecdsa_on_p521_and_the_brainpool_curves", "crates/sign/tests/it/crypto.rs::keys_of_unknown_algorithms_are_unsupported_not_wrong", "crates/sign/tests/it/crypto.rs::tolerated_encodings_are_noted_and_standard_ones_are_not", "crates/sign/tests/it/crypto.rs::weak_digests_say_so_in_words_that_are_pinned", "crates/sign/tests/it/crypto.rs::an_rsa_signature_value_not_below_the_modulus_is_refused", "crates/sign/tests/it/crypto.rs::hostile_rsa_signature_values_are_wrong_not_crashes", "crates/sign/tests/it/algorithms.rs::rsa_with_sha3_sha512_256_and_ripemd160", "crates/sign/tests/it/algorithms.rs::rsa_pss_with_its_declared_parameters", "crates/sign/tests/it/algorithms.rs::ed25519_signs_the_signed_attributes_themselves", "crates/sign/tests/it/algorithms.rs::ecdsa_on_brainpool_p512r1", "crates/sign/tests/it/algorithms.rs::brainpool_p512r1_keys_must_have_coordinates_reduced_modulo_p", "crates/sign/tests/it/algorithms.rs::only_weak_digests_leave_a_note_when_a_signature_validates", "crates/sign/tests/it/pdf.rs::validates_the_legacy_adbe_x509_rsa_sha1_format"] notes = "SHA-256, SHA-384 for P-384; SHA-1 refused for new signatures, validated in old ones. Validation also reads SHA-224, SHA-512/224, SHA-512/256, SHA3-224/256/384/512, RIPEMD-160, RSA PKCS#1 with or without the DigestInfo NULL (a bare digest without any DigestInfo is refused), RSA-PSS with its declared MGF1 hash and salt length, ECDSA as DER/non-minimal DER/raw r||s on P-256, P-384, P-521 and brainpoolP256r1/P384r1/P512r1 (the last hand-written on crypto-bigint, checked against OpenSSL), Ed25519 (RFC 8419), and the legacy adbe.x509.rsa_sha1 format. An RSA value not below the modulus is refused (RFC 8017 5.2.2). SHA-1 and RIPEMD-160 validate but the signature details say the algorithm is weak (wording pinned by tests); tolerated encodings are listed in the details. An algorithm PdfCraft cannot check gives Unknown, not Invalid. Still missing: Ed448/SHAKE, DSA, brainpool curves other than P256/P384/P512, signing with the new digests" [[feature]] @@ -6109,7 +6109,7 @@ milestone = "M9" status = "shipped" commands = ["sign.certify", "sign.certify_invisible"] tools = ["sign_document"] -evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature"] +evidence = ["crates/sign/tests/it/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/ui-egui/tests/it/signing.rs::certifying_without_a_visible_signature"] notes = "" [[feature]] @@ -6121,7 +6121,7 @@ milestone = "M9" status = "shipped" commands = ["sign.certify"] tools = ["sign_document"] -evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions"] +evidence = ["crates/sign/tests/it/pdf.rs::later_changes_are_classified_under_the_signature_permissions"] notes = "DocMDP P=1/2/3 enforced when validating" [[feature]] @@ -6145,7 +6145,7 @@ milestone = "M9" status = "partial" commands = [] tools = [] -evidence = ["crates/sign/tests/pdf.rs::a_document_timestamp_covers_the_file_and_validates"] +evidence = ["crates/sign/tests/it/pdf.rs::a_document_timestamp_covers_the_file_and_validates"] notes = "timestamp_document appends a standalone /ETSI.RFC3161 signature dictionary over the whole file; validation discovers and verifies these (imprint, token signature, TSA cert validity) and reports the trusted time. Missing: automation tool and timestamp-server configuration (transport is caller-injected)." [[feature]] @@ -6169,7 +6169,7 @@ milestone = "M9" status = "partial" commands = [] tools = [] -evidence = ["crates/sign/tests/pdf.rs::signing_with_a_timestamp_embeds_a_verified_rfc3161_token"] +evidence = ["crates/sign/tests/it/pdf.rs::signing_with_a_timestamp_embeds_a_verified_rfc3161_token"] notes = "Sign with an embedded RFC 3161 token (sign_with_timestamp): imprint and TSA signature verified at signing and validation time, trusted time reported. Missing: timestamp-server configuration and a fetcher (transport is caller-injected), the standalone document timestamp, automation tool." [[feature]] @@ -6181,7 +6181,7 @@ milestone = "M9" status = "partial" commands = [] tools = [] -evidence = ["crates/sign/tests/pdf.rs::embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid", "crates/sign/tests/pdf.rs::an_embedded_verified_revocation_invalidates_the_signature"] +evidence = ["crates/sign/tests/it/pdf.rs::embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid", "crates/sign/tests/it/pdf.rs::an_embedded_verified_revocation_invalidates_the_signature"] notes = "dss::embed merges caller-supplied evidence into the catalog /DSS with /VRI entries per signature, deduplicating blobs, as a permitted incremental change; revocation.rs verifies the embedded CRLs and OCSP responses (signature, responder identity, CertID, window) and a verified revocation invalidates the signature. Missing: fetching evidence, automation tool." [[feature]] @@ -6193,7 +6193,7 @@ milestone = "M9" status = "partial" commands = [] tools = [] -evidence = ["crates/sign/tests/pdf.rs::sign_then_ltv_then_timestamp_makes_a_b_lta_file"] +evidence = ["crates/sign/tests/it/pdf.rs::sign_then_ltv_then_timestamp_makes_a_b_lta_file"] notes = "The B-LT/B-LTA chain works with caller-supplied evidence: sign, embed DSS (B-LT), then append a document timestamp over the result (B-LTA); earlier signatures stay valid with the store classified as a permitted change. Missing: OCSP/CRL verification of the evidence, re-timestamping automation." [[feature]] @@ -6205,7 +6205,7 @@ milestone = "M9" status = "partial" commands = [] tools = [] -evidence = ["crates/sign/tests/crypto.rs::crls_verify_and_report_revocation", "crates/sign/tests/crypto.rs::ocsp_responses_verify_and_match_the_certificate", "crates/sign/tests/pdf.rs::an_embedded_verified_revocation_invalidates_the_signature"] +evidence = ["crates/sign/tests/it/crypto.rs::crls_verify_and_report_revocation", "crates/sign/tests/it/crypto.rs::ocsp_responses_verify_and_match_the_certificate", "crates/sign/tests/it/pdf.rs::an_embedded_verified_revocation_invalidates_the_signature"] notes = "RFC 5280 CRLs and RFC 6960 OCSP responses are parsed and verified (issuer/responder signature, delegated responder's OCSP-signing EKU, CertID hash match, validity window) and checked against the DSS during validation. Missing: network fetching of fresh evidence and the OS trust store." [[feature]] @@ -6217,7 +6217,7 @@ milestone = "M9" status = "shipped" commands = ["sign.validate"] tools = ["sign_list"] -evidence = ["crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/sign/tests/chain.rs::a_trusted_signer_whose_certificate_may_not_sign_is_not_valid"] +evidence = ["crates/sign/tests/it/pdf.rs::signing_then_validating_with_and_without_trust", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/sign/tests/it/chain.rs::a_trusted_signer_whose_certificate_may_not_sign_is_not_valid"] notes = "The signer's key usage, extended key usage and critical extensions are checked; name constraints are not" [[feature]] @@ -6229,7 +6229,7 @@ milestone = "M9" status = "partial" commands = ["sign.panel"] tools = ["sign_list"] -evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] +evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] notes = "per-signature tree with details and actions; no per-revision page outline yet" [[feature]] @@ -6241,7 +6241,7 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = ["sign_list"] -evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] +evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] notes = "" [[feature]] @@ -6253,7 +6253,7 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = [] -evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature", "crates/ui-egui/src/canvas.rs::signature_badges_keep_signed_visible_widgets_on_the_page"] +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.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." [[feature]] @@ -6265,7 +6265,7 @@ milestone = "M9" status = "partial" commands = ["sign.panel"] tools = ["sign_list"] -evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] +evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] notes = "validity summary, signer, issuer, time, algorithm in the panel; no separate properties dialog" [[feature]] @@ -6277,7 +6277,7 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = ["sign_list"] -evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] +evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] notes = "Signatures panel ▸ Show certificate: the chain (root on top) and Summary, Details (serial, key, constraints, usage, SHA-1/SHA-256 digests) and Trust tabs; add to trusted certificates; export" [[feature]] @@ -6289,7 +6289,7 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = ["sign_trust"] -evidence = ["crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +evidence = ["crates/sign/tests/it/pdf.rs::signing_then_validating_with_and_without_trust", "crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] notes = "trusted certificates persist with the app's settings" [[feature]] @@ -6301,7 +6301,7 @@ milestone = "M9" status = "partial" commands = [] tools = ["sign_trust"] -evidence = ["crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +evidence = ["crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] notes = "import .cer/.pem/.der/.p7b/.p7c/.p12 through the tool; per-certificate trust settings not yet" [[feature]] @@ -6337,7 +6337,7 @@ milestone = "M9" status = "partial" commands = [] tools = [] -evidence = ["crates/sign/tests/pdf.rs::signing_with_a_timestamp_embeds_a_verified_rfc3161_token", "crates/sign/tests/pdf.rs::an_untrusted_timestamp_does_not_set_the_validation_time"] +evidence = ["crates/sign/tests/it/pdf.rs::signing_with_a_timestamp_embeds_a_verified_rfc3161_token", "crates/sign/tests/it/pdf.rs::an_untrusted_timestamp_does_not_set_the_validation_time"] notes = "Validation prefers the verified RFC 3161 timestamp time over /M when checking certificate validity and revocation, but only when the TSA chains to the trust store; an untrusted token is reported as an unverified timestamp and its time is not used. Missing: an explicit verification-time option (verify at a chosen date / now) in the validation API." [[feature]] @@ -6349,7 +6349,7 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = ["sign_list"] -evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry", "crates/sign/tests/pdf.rs::rewriting_a_certified_forms_xfa_data_is_a_form_fill"] +evidence = ["crates/sign/tests/it/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/it/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/it/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/it/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/it/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/it/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/it/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/it/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/it/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/it/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/it/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry", "crates/sign/tests/it/pdf.rs::rewriting_a_certified_forms_xfa_data_is_a_form_fill"] notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787). Rewriting the XFA `datasets` or `form` packet the signed revision named is form fill, as a Reader Extensions (UR3) save of a certified form does (#2759); the packets' roles come from the signed revision" [[feature]] @@ -6361,7 +6361,7 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = [] -evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] +evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"] notes = "repeats reuse the tab, whose name keeps the (signed version) suffix instead of the document title; the tab carries a notice bar naming the revision, with a Close that asks about saving first. Still missing: the snapshot is a full editable document rather than a read-only view, and there is no side-by-side compare (sign.compare-signed-version)" [[feature]] @@ -6497,7 +6497,7 @@ milestone = "M10" status = "shipped" commands = ["ocr.recognize"] tools = ["ocr_recognize", "ocr_status"] -evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/ocr.rs::recognize_text_dialog_adds_searchable_text"] +evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"] notes = "ocrs engine (MIT/Apache) with CC-BY-SA models fetched by `cargo xtask models`; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)." [[feature]] @@ -6509,7 +6509,7 @@ milestone = "M10" status = "shipped" commands = ["ocr.recognize"] tools = ["ocr_recognize"] -evidence = ["crates/automation/tests/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/ocr.rs::recognize_text_dialog_adds_searchable_text"] +evidence = ["crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"] notes = "All pages, current page or a range." [[feature]] @@ -6521,7 +6521,7 @@ milestone = "M10" status = "shipped" commands = ["ocr.recognize_batch"] tools = ["ocr_recognize_files"] -evidence = ["crates/automation/tests/automation.rs::ocr_recognize_files_writes_searchable_copies", "crates/ui-egui/tests/ocr.rs::recognize_text_in_multiple_files_writes_searchable_copies"] +evidence = ["crates/automation/tests/it/automation.rs::ocr_recognize_files_writes_searchable_copies", "crates/ui-egui/tests/it/ocr.rs::recognize_text_in_multiple_files_writes_searchable_copies"] notes = "Pick PDFs, then an output folder; copies are saved incrementally under the same names, in the background with progress." [[feature]] @@ -6533,7 +6533,7 @@ milestone = "M10" status = "partial" commands = ["ocr.recognize"] tools = ["ocr_recognize"] -evidence = ["crates/ui-egui/tests/ocr.rs::recognize_text_dialog_adds_searchable_text"] +evidence = ["crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"] notes = "Language choice exists, but the models read only the Latin alphabet without accents (English)." [[feature]] @@ -6569,7 +6569,7 @@ milestone = "M10" status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/engine/tests/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open"] +evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open"] notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Still missing: Acrobat's Recognize Text output that converts a whole page to editable text at once, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." [[feature]] @@ -6593,7 +6593,7 @@ milestone = "M10" status = "shipped" commands = ["ocr.recognize"] tools = ["ocr_recognize"] -evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/automation.rs::ocr_tools_make_a_scan_searchable", "crates/engine/src/tests.rs::recognize_text_reads_a_picture_on_a_page_that_has_text", "crates/automation/tests/automation.rs::ocr_recognize_reads_pictures_on_pages_with_text", "crates/ui-egui/tests/ocr.rs::recognize_text_can_read_pictures_on_pages_with_text"] +evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/engine/src/tests.rs::recognize_text_reads_a_picture_on_a_page_that_has_text", "crates/automation/tests/it/automation.rs::ocr_recognize_reads_pictures_on_pages_with_text", "crates/ui-egui/tests/it/ocr.rs::recognize_text_can_read_pictures_on_pages_with_text"] notes = "Pages that already have text are skipped and reported by default. The dialog's \"Also recognize pages that already have text\" (skip_text_pages false, also for multiple files) reads pictures on such pages (#1028); a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out and counted (already_on_page). Missing: recognising only the image areas (the whole page is still read, so it takes as long as a scan), and a word in a picture that sits under real text is treated as that text." [[feature]] @@ -6629,7 +6629,7 @@ milestone = "M10" status = "partial" commands = ["ocr.enhance_camera"] tools = ["page_enhance_camera"] -evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] +evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] notes = "Camera images only: from clean-up level 60, isolated dark pixels are removed. Missing: for scanned pages and as an OCR option." [[feature]] @@ -6641,7 +6641,7 @@ milestone = "M10" status = "partial" commands = ["ocr.enhance_camera"] tools = ["page_enhance_camera"] -evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] +evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] notes = "Camera images only: Enhance camera image's clean-up level (and page_enhance_camera level) evens out the paper's illumination per channel, so the background turns white and colour casts go; colour ink stays. Missing: the same for scanned pages and as an OCR option." [[feature]] @@ -6677,7 +6677,7 @@ milestone = "M10" status = "partial" commands = ["ocr.enhance_camera"] tools = ["page_enhance_camera"] -evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] +evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] notes = "Camera images only: the clean-up level divides out a smooth illumination estimate, which removes shadows and darker edges on the straightened photo. Missing: for scanned pages (book gutters) and as an OCR option." [[feature]] @@ -6689,7 +6689,7 @@ milestone = "M10" status = "partial" commands = ["ocr.enhance_camera"] tools = ["page_enhance_camera"] -evidence = ["crates/engine/src/tests.rs::enhance_camera_page_straightens_the_sheet", "crates/automation/tests/automation.rs::enhance_camera_image_through_tools", "crates/ui-egui/tests/camera.rs::dragging_a_corner_then_enhance_page_straightens_the_page", "crates/ui-egui/tests/camera.rs::after_enhance_page_crop_and_organize_take_over", "crates/engine/src/tests.rs::detect_camera_corners_finds_the_sheet_in_user_space", "crates/automation/tests/automation.rs::enhance_camera_image_finds_the_edges_by_itself"] +evidence = ["crates/engine/src/tests.rs::enhance_camera_page_straightens_the_sheet", "crates/automation/tests/it/automation.rs::enhance_camera_image_through_tools", "crates/ui-egui/tests/it/camera.rs::dragging_a_corner_then_enhance_page_straightens_the_page", "crates/ui-egui/tests/it/camera.rs::after_enhance_page_crop_and_organize_take_over", "crates/engine/src/tests.rs::detect_camera_corners_finds_the_sheet_in_user_space", "crates/automation/tests/it/automation.rs::enhance_camera_image_finds_the_edges_by_itself"] notes = "Four corners (any order, a convex quadrilateral) are warped to an upright page at the picture's own resolution, in one undoable step; comments, links and bookmarks stay. Scan & OCR ▸ Enhance camera image shows four corner handles on the current page (Enhance page / Cancel / Esc), then offers rotate either way, Crop pages and Organize pages for the enhanced page; page_enhance_camera does the same headlessly. The corners start on the document's edges when they are found (straight lines with an edge along most of each side, refined by a line fit; tested on synthetic photos with text, wood grain, shadows, strong perspective and a 30° turn), else inset 5%. After Enhance page a clean-up slider (0–100, default 50) re-enhances from the straightened photo: even lighting, white paper, contrast, despeckle from 60 (camera_clean). Missing: turning the result upright (text orientation), rounded-corner cards keep their background in the corners, document types." [[feature]] @@ -6849,7 +6849,7 @@ milestone = "M10" status = "shipped" commands = ["create.file"] tools = ["doc_create"] -evidence = ["crates/create/src/tests.rs::text_is_wrapped_and_paginated", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools"] +evidence = ["crates/create/src/tests.rs::text_is_wrapped_and_paginated", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools"] notes = "Helvetica 11 pt on US Letter; form feeds start pages" [[feature]] @@ -6873,7 +6873,7 @@ milestone = "M10" status = "shipped" commands = ["create.clipboard"] tools = [] -evidence = ["crates/ui-egui/tests/create.rs::clipboard_images_and_text_become_new_pdfs"] +evidence = ["crates/ui-egui/tests/it/create.rs::clipboard_images_and_text_become_new_pdfs"] notes = "an image (one page at its size) or text; headless tools read files instead" [[feature]] @@ -6897,7 +6897,7 @@ milestone = "M10" status = "shipped" commands = ["create.blank"] tools = ["doc_create"] -evidence = ["crates/ui-egui/tests/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools"] +evidence = ["crates/ui-egui/tests/it/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools"] notes = "" [[feature]] @@ -6945,7 +6945,7 @@ milestone = "M10" status = "shipped" commands = ["create.multiple"] tools = ["doc_create_multiple"] -evidence = ["crates/engine/src/tests.rs::mixed_files_convert_and_combine_in_order", "crates/engine/src/tests.rs::files_that_cannot_be_converted_are_refused_clearly", "crates/automation/tests/automation.rs::creating_from_multiple_files_through_tools", "crates/ui-egui/tests/create.rs::multiple_files_open_as_one_document_in_the_page_grid", "crates/ui-egui/tests/editing.rs::plus_between_pages_inserts_picked_files_there_in_order", "crates/ui-egui/tests/editing.rs::save_pages_writes_what_the_grid_shows"] +evidence = ["crates/engine/src/tests.rs::mixed_files_convert_and_combine_in_order", "crates/engine/src/tests.rs::files_that_cannot_be_converted_are_refused_clearly", "crates/automation/tests/it/automation.rs::creating_from_multiple_files_through_tools", "crates/ui-egui/tests/it/create.rs::multiple_files_open_as_one_document_in_the_page_grid", "crates/ui-egui/tests/it/editing.rs::plus_between_pages_inserts_picked_files_there_in_order", "crates/ui-egui/tests/it/editing.rs::save_pages_writes_what_the_grid_shows"] notes = "Create ▸ Multiple files: the picked PDFs, images (PNG, JPEG, JPEG 2000, TIFF, GIF, BMP) and .txt files open as one unsaved document in the page grid, in the order picked (a bookmark per file); there pages are removed, reordered, more files inserted at any gap (a + button, or dropped there), and Save pages writes what is shown. One PDF per file is offered by the doc_create_multiple tool only (mode separate), not in the app. Missing: Office, HTML and Markdown inputs, PDF Portfolio output, per-file image resolution choice, progress and cancel for large batches (conversion runs on the UI thread). The combined file has combine's limits: untagged, no page labels or document info carried over, and its per-file bookmarks are not updated when pages are later moved between files." [[feature]] @@ -6957,7 +6957,7 @@ milestone = "M10" status = "partial" commands = ["create.images"] tools = ["doc_create"] -evidence = ["crates/create/src/tests.rs::image_resolution_override_changes_size_without_resampling", "crates/automation/tests/automation.rs::creating_images_with_dpi_through_tools", "crates/ui-egui/tests/create.rs::image_import_dialog_chooses_dpi_and_cancels"] +evidence = ["crates/create/src/tests.rs::image_resolution_override_changes_size_without_resampling", "crates/automation/tests/it/automation.rs::creating_images_with_dpi_through_tools", "crates/ui-egui/tests/it/create.rs::image_import_dialog_chooses_dpi_and_cancels"] notes = "Page sizing: embedded resolution (72 dpi fallback), fixed 72 dpi, or custom 1–1200 dpi; pixels are not resampled. Compression options are still absent. Windows image context menu opens the same chooser; other OS context-menu integration not yet." [[feature]] @@ -7005,7 +7005,7 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background"] +evidence = ["crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background"] notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." [[feature]] @@ -7017,7 +7017,7 @@ milestone = "M10" status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] +evidence = ["crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"] notes = "Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; page breaks. Images are left out." [[feature]] @@ -7055,8 +7055,8 @@ evidence = [ "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", - "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", - "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf", + "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", + "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", ] notes = "PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text, images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." @@ -7069,7 +7069,7 @@ milestone = "M10" status = "shipped" commands = ["export.image"] tools = ["doc_export_images", "page_render"] -evidence = ["crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text", "crates/engine/src/tests.rs::export_images_and_text_follow_the_working_file", "crates/automation/tests/automation.rs::exporting_images_and_text_through_tools"] +evidence = ["crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/engine/src/tests.rs::export_images_and_text_follow_the_working_file", "crates/automation/tests/it/automation.rs::exporting_images_and_text_through_tools"] notes = "PNG at 72–600 dpi, page ranges; includes unsaved edits; background thread with progress" [[feature]] @@ -7081,7 +7081,7 @@ milestone = "M10" status = "shipped" commands = ["export.image"] tools = ["doc_export_images"] -evidence = ["crates/engine/src/tests.rs::pages_export_as_png_jpeg_and_tiff", "crates/automation/tests/automation.rs::exporting_images_and_text_through_tools", "crates/engine/src/export.rs::rasters_of_the_wrong_length_are_rejected"] +evidence = ["crates/engine/src/tests.rs::pages_export_as_png_jpeg_and_tiff", "crates/automation/tests/it/automation.rs::exporting_images_and_text_through_tools", "crates/engine/src/export.rs::rasters_of_the_wrong_length_are_rejected"] notes = "JPEG with quality, TIFF; flattened onto white; a raster that is not exactly its width × height in pixels is refused, as for PNG" [[feature]] @@ -7105,7 +7105,7 @@ milestone = "M10" status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] -evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/automation/tests/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] +evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] @@ -7117,7 +7117,7 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"] +evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"] notes = "Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." [[feature]] @@ -7129,7 +7129,7 @@ milestone = "M10" status = "shipped" commands = ["export.text"] tools = ["doc_export_text", "text_extract"] -evidence = ["crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text", "crates/automation/tests/automation.rs::exporting_images_and_text_through_tools"] +evidence = ["crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/automation/tests/it/automation.rs::exporting_images_and_text_through_tools"] notes = "plain reading-order text; accessible text (tags) comes with M12" [[feature]] @@ -7225,7 +7225,7 @@ milestone = "M2" status = "shipped" commands = [] tools = ["text_extract"] -evidence = ["crates/render/src/text.rs::rtl_runs_come_out_in_logical_order_and_cjk_has_no_spaces", "crates/render/src/text.rs::lines_words_and_search", "crates/automation/tests/automation.rs::parallel_text_extraction_keeps_page_order_and_follows_edits"] +evidence = ["crates/render/src/text.rs::rtl_runs_come_out_in_logical_order_and_cjk_has_no_spaces", "crates/render/src/text.rs::lines_words_and_search", "crates/automation/tests/it/automation.rs::parallel_text_extraction_keeps_page_order_and_follows_edits"] notes = "" [[feature]] @@ -7265,7 +7265,7 @@ milestone = "M11" status = "shipped" commands = ["optimize.reduce"] tools = ["doc_reduce"] -evidence = ["crates/ui-egui/tests/create.rs::reduce_file_size_writes_a_compact_copy", "crates/ui-egui/tests/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] +evidence = ["crates/ui-egui/tests/it/create.rs::reduce_file_size_writes_a_compact_copy", "crates/ui-egui/tests/it/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" [[feature]] @@ -7289,7 +7289,7 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/automation/tests/optimize_content_boundaries.rs::fragment_merge_preserves_a_comment_across_content_pieces", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"] +evidence = ["crates/automation/tests/it/optimize_content_boundaries.rs::fragment_merge_preserves_a_comment_across_content_pieces", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/it/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/it/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"] notes = "Settings menu with the Standard and Mobile presets (Custom once an option changes; doc_optimize preset), Images (with Merge image fragments: a page picture drawn as one-pixel strips, as some PDF printers write it, is put back together so it can be resampled), Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no saved custom presets, no space audit" [[feature]] @@ -7301,7 +7301,7 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured"] +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured"] notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet" [[feature]] @@ -7373,7 +7373,7 @@ milestone = "M11" status = "shipped" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"] +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/it/automation.rs::optimizing_through_tools"] notes = "with links and actions (Remove Hidden Information)" [[feature]] @@ -7397,7 +7397,7 @@ milestone = "M11" status = "shipped" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::discards_and_clean_up", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] +evidence = ["crates/optimize/tests/optimize.rs::discards_and_clean_up", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] notes = "" [[feature]] @@ -7409,7 +7409,7 @@ milestone = "M11" status = "shipped" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"] +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/it/automation.rs::optimizing_through_tools"] notes = "" [[feature]] @@ -7469,7 +7469,7 @@ milestone = "M11" status = "shipped" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"] +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/it/automation.rs::optimizing_through_tools"] notes = "" [[feature]] @@ -7481,7 +7481,7 @@ milestone = "M11" status = "shipped" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"] +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/it/automation.rs::optimizing_through_tools"] notes = "" [[feature]] @@ -7493,7 +7493,7 @@ milestone = "M11" status = "shipped" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] +evidence = ["crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] notes = "" [[feature]] @@ -7505,7 +7505,7 @@ milestone = "M11" status = "shipped" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"] +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/it/automation.rs::optimizing_through_tools"] notes = "" [[feature]] @@ -7517,7 +7517,7 @@ milestone = "M11" status = "shipped" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"] +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/it/automation.rs::optimizing_through_tools"] notes = "" [[feature]] @@ -7541,7 +7541,7 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/automation.rs::optimizing_through_tools"] +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/automation/tests/it/automation.rs::optimizing_through_tools"] notes = "hidden layer content removed; visible layers not flattened" [[feature]] @@ -7577,7 +7577,7 @@ milestone = "M11" status = "partial" commands = ["file.save_as"] tools = ["doc_save"] -evidence = ["crates/cos/tests/writer.rs::full_save_packs_objects_into_object_streams"] +evidence = ["crates/cos/tests/it/writer.rs::full_save_packs_objects_into_object_streams"] notes = "on by default for full saves; no Optimizer option yet" [[feature]] @@ -7625,7 +7625,7 @@ milestone = "M11" status = "shipped" commands = ["optimize.advanced"] tools = ["doc_audit_space"] -evidence = ["crates/optimize/tests/optimize.rs::space_audit_shares_the_file_out_by_kind", "crates/optimize/tests/optimize.rs::space_audit_counts_what_fields_and_comments_draw_with_by_kind", "crates/optimize/tests/optimize.rs::space_audit_survives_deeply_nested_forms", "crates/automation/tests/automation.rs::auditing_space_through_tools", "crates/ui-egui/tests/viewer.rs::optimizer_audits_space_usage", "crates/ui-egui/tests/viewer.rs::space_audit_numbers_line_up_under_their_headers"] +evidence = ["crates/optimize/tests/optimize.rs::space_audit_shares_the_file_out_by_kind", "crates/optimize/tests/optimize.rs::space_audit_counts_what_fields_and_comments_draw_with_by_kind", "crates/optimize/tests/optimize.rs::space_audit_survives_deeply_nested_forms", "crates/automation/tests/it/automation.rs::auditing_space_through_tools", "crates/ui-egui/tests/it/viewer.rs::optimizer_audits_space_usage", "crates/ui-egui/tests/it/viewer.rs::space_audit_numbers_line_up_under_their_headers"] notes = "PDF Optimizer ▸ Audit space usage: what the file's bytes are spent on now (not what optimizing would save), in 16 categories (images, content streams, fonts, X Object forms, structure, bookmarks, thumbnails, comments, forms, named destinations, colour spaces, shadings, patterns, graphics states, embedded files, overhead) with bytes and percentages. Resources are counted by kind wherever they are used, so the fonts and images form fields and comments draw with count as fonts and images. Still missing: sizes are each object as it would be written uncompressed, so objects packed in compressed object streams count larger than they take on disk (overhead, the rest of the file, shrinks to match)" [[feature]] @@ -7805,7 +7805,7 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_verify"] -evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"] +evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] @@ -7817,7 +7817,7 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_convert"] -evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"] +evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship. Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1." [[feature]] @@ -7865,7 +7865,7 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_convert", "pdfa_verify"] -evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"] +evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] notes = "sRGB output intent added and listed; no choice of other conditions yet." [[feature]] @@ -7913,7 +7913,7 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_verify"] -evidence = ["crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"] +evidence = ["crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] notes = "Declared PDF/A, PDF/UA and output intents shown in the PDF/A dialog and returned by pdfa_verify." [[feature]] @@ -7925,7 +7925,7 @@ milestone = "M11" status = "shipped" commands = ["standards.pdfa"] tools = ["standards_remove_id"] -evidence = ["crates/organize/src/tests.rs::removing_the_pdfx_identification_keeps_the_output_intent_and_other_claims", "crates/organize/src/tests.rs::removing_the_pdfa_identification_removes_its_xmp_properties_in_either_form", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"] +evidence = ["crates/organize/src/tests.rs::removing_the_pdfx_identification_keeps_the_output_intent_and_other_claims", "crates/organize/src/tests.rs::removing_the_pdfa_identification_removes_its_xmp_properties_in_either_form", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] notes = "Standards ▸ PDF/A ▸ Remove PDF/A identification, and the standards_remove_id tool (pdfa or pdfx): removes the pdfaid: XMP properties, or GTS_PDFXVersion and GTS_PDFXConformance from the document information and the pdfxid: XMP properties. Output intents and other claims (PDF/UA) stay; a packet that can't be edited safely is left as it is. Not yet: PDF/X removal in the interface (there is no PDF/X dialog yet)" [[feature]] @@ -7965,7 +7965,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print", "printers"] -evidence = ["crates/ui-egui/tests/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page"] +evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page"] notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name; this change does not alter persistence of other print preferences" [[feature]] @@ -7977,7 +7977,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["printers", "printer_options", "doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"] +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"] notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818)" [[feature]] @@ -8001,7 +8001,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::page_selection", "crates/automation/tests/automation.rs::printing_through_tools"] +evidence = ["crates/print/src/tests.rs::page_selection", "crates/automation/tests/it/automation.rs::printing_through_tools"] notes = "all, current, ranges with labels and open ends" [[feature]] @@ -8025,7 +8025,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/print/src/tests.rs::us_number_10_envelope_is_offered_and_fed_by_name", "crates/automation/tests/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/print.rs::architectural_page_can_be_matched_at_100_percent"] +evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/print/src/tests.rs::us_number_10_envelope_is_offered_and_fed_by_name", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent"] notes = "fit, actual size, shrink oversized, custom %; ARCH A/B/C/D/E1/E, ANSI C/D/E, ISO A0/A1/A2, A6 and US #10 envelope presets; Match page at 100% matches the current page to a standard sheet. PDF scale shows the previewed source-to-sheet transform, not calibrated physical output; driver substitution/scaling remains possible" [[feature]] @@ -8061,7 +8061,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::multiple_pages_per_sheet", "crates/automation/tests/automation.rs::printing_through_tools", "crates/print/src/tests.rs::cut_stack_keeps_piles_in_order_and_blanks_in_place", "crates/print/src/tests.rs::cutting_and_stacking_recovers_every_selected_page", "crates/automation/tests/automation.rs::cut_stack_printing_through_tools", "crates/ui-egui/tests/print.rs::cut_stack_dialog_previews_saves_and_refuses_duplex"] +evidence = ["crates/print/src/tests.rs::multiple_pages_per_sheet", "crates/automation/tests/it/automation.rs::printing_through_tools", "crates/print/src/tests.rs::cut_stack_keeps_piles_in_order_and_blanks_in_place", "crates/print/src/tests.rs::cutting_and_stacking_recovers_every_selected_page", "crates/automation/tests/it/automation.rs::cut_stack_printing_through_tools", "crates/ui-egui/tests/it/print.rs::cut_stack_dialog_previews_saves_and_refuses_duplex"] notes = "2/4/6/9/16, four sequential orders, borders, auto-rotate; also single-sided cut-and-stack order with aligned gutter cut marks, blank padding and range/reverse support (an extension, not an Acrobat parity claim). Duplex cut-and-stack is refused." [[feature]] @@ -8085,7 +8085,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::posters_tile_with_overlap", "crates/automation/tests/automation.rs::printing_through_tools"] +evidence = ["crates/print/src/tests.rs::posters_tile_with_overlap", "crates/automation/tests/it/automation.rs::printing_through_tools"] notes = "tile scale, overlap, cut marks (labels pending)" [[feature]] @@ -8097,7 +8097,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/print.rs::architectural_page_can_be_matched_at_100_percent"] +evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent"] notes = "auto portrait/landscape" [[feature]] @@ -8133,7 +8133,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = [] -evidence = ["crates/ui-egui/tests/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf"] +evidence = ["crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf"] notes = "live sheet preview with sheet navigation" [[feature]] @@ -8145,7 +8145,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/automation.rs::doc_print_as_image"] +evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/it/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/it/automation.rs::doc_print_as_image"] notes = "asks the printer for monochrome (lp -o, and the Windows page settings as the job's); with Print as image the pictures themselves are rendered grey (Rec. 601 luma), so the print-ready PDF is grayscale too. Still missing against Acrobat: a grey conversion of the vector pages without rendering them." [[feature]] @@ -8157,7 +8157,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/automation.rs::doc_print_as_image"] +evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/it/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/it/automation.rs::doc_print_as_image"] notes = "The Print dialog's Advanced section (folded unless on): Print as image at 150, 300 or 600 dpi; doc_print as_image and dpi (72–600). The pages used are rendered once each the way the viewer shows them (comments left out when the print leaves them out), composed over white, made into an image-only document and imposed on the paper like the pages themselves. The print is no longer searchable text. Still missing against Acrobat: per-printer defaults for it." [[feature]] @@ -8181,7 +8181,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/ui-egui/tests/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/automation/tests/automation.rs::printing_through_tools", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render"] +evidence = ["crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/automation/tests/it/automation.rs::printing_through_tools", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render"] notes = "Save as PDF from the dialog" [[feature]] @@ -8193,7 +8193,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::print_shortcut_offers_the_pages_picked_in_the_pages_panel", "crates/ui-egui/tests/editing.rs::pages_panel_command_click_picks_pages_without_moving_the_document", "crates/ui-egui/tests/editing.rs::pages_panel_shift_click_picks_a_range_and_a_plain_click_starts_over", "crates/ui-egui/tests/print.rs::print_dialog_prints_the_selected_pages", "crates/print/src/tests.rs::page_selection"] +evidence = ["crates/ui-egui/tests/it/editing.rs::print_shortcut_offers_the_pages_picked_in_the_pages_panel", "crates/ui-egui/tests/it/editing.rs::pages_panel_command_click_picks_pages_without_moving_the_document", "crates/ui-egui/tests/it/editing.rs::pages_panel_shift_click_picks_a_range_and_a_plain_click_starts_over", "crates/ui-egui/tests/it/print.rs::print_dialog_prints_the_selected_pages", "crates/print/src/tests.rs::page_selection"] notes = "Ctrl/⌘-click and ⇧-click pick thumbnails in the Pages panel (and the Organize grid); Print opens on \"Selected pages\" and prints them in page order. Missing: the thumbnails' own context-menu Print command, picking by dragging a marquee, arrow-key selection, and a printer on Windows (Save as PDF only there, see print.printer-selection)." [[feature]] @@ -8437,7 +8437,7 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id"] +evidence = ["crates/ui-egui/tests/it/control.rs::inspect_and_click_by_label_and_id"] notes = "AccessKit labels on all custom widgets; full audit in M14.3" [[feature]] @@ -8461,7 +8461,7 @@ milestone = "M12" status = "shipped" commands = ["a11y.check"] tools = ["accessibility_check"] -evidence = ["crates/a11y/src/tests.rs::an_untagged_document_fails_the_document_rules", "crates/a11y/src/tests.rs::a_well_tagged_document_passes", "crates/a11y/src/tests.rs::structure_findings_identify_indirect_elements_without_guessing_inline_objects", "crates/a11y/src/tests.rs::figures_are_listed_with_their_alt_text_and_place", "crates/a11y/src/tests.rs::annotation_findings_only_locate_valid_rectangles", "crates/automation/tests/automation.rs::accessibility_check_locates_field_description_on_page", "crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/ui-egui/tests/accessibility.rs::checker_shows_a_figure_finding_on_its_page", "crates/ui-egui/tests/accessibility.rs::checker_summary_does_not_hide_manual_review", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +evidence = ["crates/a11y/src/tests.rs::an_untagged_document_fails_the_document_rules", "crates/a11y/src/tests.rs::a_well_tagged_document_passes", "crates/a11y/src/tests.rs::structure_findings_identify_indirect_elements_without_guessing_inline_objects", "crates/a11y/src/tests.rs::figures_are_listed_with_their_alt_text_and_place", "crates/a11y/src/tests.rs::annotation_findings_only_locate_valid_rectangles", "crates/automation/tests/it/automation.rs::accessibility_check_locates_field_description_on_page", "crates/ui-egui/tests/it/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/ui-egui/tests/it/accessibility.rs::checker_shows_a_figure_finding_on_its_page", "crates/ui-egui/tests/it/accessibility.rs::checker_summary_does_not_hide_manual_review", "crates/automation/tests/it/automation.rs::accessibility_check_report_and_fixes_through_tools"] notes = "full check: options dialog (report, page range, 32 rules by category, 31 on by default), results panel by category with Passed / Failed / Needs manual check / Skipped and findings that go to their page. A zero-failure check reports manual or skipped rules rather than claiming no issues. Locatable figure, annotation and form-widget findings flash their bounds on the page; accessibility_check exposes those bounds in PDF user space. Other findings navigate to their page. Structure findings expose the indirect tag object number and generation through accessibility_check, when available, for precise follow-up in the Tags Panel." [[feature]] @@ -8473,7 +8473,7 @@ milestone = "M12" status = "shipped" commands = ["a11y.report"] tools = ["accessibility_report", "accessibility_review_record", "accessibility_review_html"] -evidence = ["crates/a11y/src/tests.rs::rules_have_ids_and_the_report_lists_them", "crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/automation/tests/automation.rs::accessibility_review_record_compares_two_versions_without_changing_them", "crates/automation/src/a11y.rs::document_names_are_escaped_in_review_html"] +evidence = ["crates/a11y/src/tests.rs::rules_have_ids_and_the_report_lists_them", "crates/ui-egui/tests/it/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/automation/tests/it/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/automation/tests/it/automation.rs::accessibility_review_record_compares_two_versions_without_changing_them", "crates/automation/src/a11y.rs::document_names_are_escaped_in_review_html"] notes = "HTML checker report: summary counts and a table per category with descriptions and findings. The headline shows manual or skipped rules when there are no automated failures. Headless comparison of two open versions exports the same before-and-after evidence as JSON or accessible, self-contained HTML with hashes, rule changes, and remaining review items." [[feature]] @@ -8485,7 +8485,7 @@ milestone = "M12" status = "shipped" commands = ["a11y.check"] tools = ["accessibility_fix", "accessibility_review_record"] -evidence = ["crates/ui-egui/tests/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/automation/tests/automation.rs::accessibility_review_record_compares_two_versions_without_changing_them"] +evidence = ["crates/ui-egui/tests/it/accessibility.rs::checking_fixing_skipping_and_reporting", "crates/automation/tests/it/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/automation/tests/it/automation.rs::accessibility_review_record_compares_two_versions_without_changing_them"] notes = "right-click a rule: Fix (language via Document Properties, title, tab order), Skip Rule (kept on Check Again), Explain, Check Again, Show Report, Options. The panel compares outcome counts and changed rules with the previous check of the same document and options; the headless review record compares two open revisions." [[feature]] @@ -8545,7 +8545,7 @@ milestone = "M12" status = "shipped" commands = ["a11y.check"] tools = ["accessibility_check"] -evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +evidence = ["crates/automation/tests/it/automation.rs::accessibility_check_report_and_fixes_through_tools"] notes = "rule primary-language: catalog /Lang; fix via Document Properties or accessibility_fix" [[feature]] @@ -8557,7 +8557,7 @@ milestone = "M12" status = "shipped" commands = ["a11y.check"] tools = ["accessibility_check"] -evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +evidence = ["crates/automation/tests/it/automation.rs::accessibility_check_report_and_fixes_through_tools"] notes = "rule title: Info Title and DisplayDocTitle; Fix sets both" [[feature]] @@ -8581,7 +8581,7 @@ milestone = "M12" status = "shipped" commands = ["a11y.check"] tools = ["accessibility_check"] -evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +evidence = ["crates/automation/tests/it/automation.rs::accessibility_check_report_and_fixes_through_tools"] notes = "rule color-contrast: needs manual check; off by default as in Acrobat" [[feature]] @@ -8629,7 +8629,7 @@ milestone = "M12" status = "shipped" commands = ["a11y.check"] tools = ["accessibility_check"] -evidence = ["crates/a11y/src/tests.rs::scans_identity_fonts_and_long_documents", "crates/a11y/src/tests.rs::character_encoding_accepts_identity_cjk_collections", "crates/a11y/src/tests.rs::character_encoding_accepts_predefined_cmaps_without_a_known_collection", "crates/a11y/src/tests.rs::character_encoding_rejects_unknown_cmaps_and_foreign_collections", "crates/a11y/src/tests.rs::character_encoding_resolves_indirect_cid_metadata", "crates/automation/tests/automation.rs::accessibility_character_encoding_accepts_identity_cjk_through_tools"] +evidence = ["crates/a11y/src/tests.rs::scans_identity_fonts_and_long_documents", "crates/a11y/src/tests.rs::character_encoding_accepts_identity_cjk_collections", "crates/a11y/src/tests.rs::character_encoding_accepts_predefined_cmaps_without_a_known_collection", "crates/a11y/src/tests.rs::character_encoding_rejects_unknown_cmaps_and_foreign_collections", "crates/a11y/src/tests.rs::character_encoding_resolves_indirect_cid_metadata", "crates/automation/tests/it/automation.rs::accessibility_character_encoding_accepts_identity_cjk_through_tools"] notes = "rule character-encoding: ToUnicode, a standard encoding, a predefined non-Identity CJK CMap, or Adobe GB1/CNS1/Japan1/Korea1/KR; Identity-H/V accepted for those collections (#3673), indirect metadata resolved and unknown CMaps/foreign registries refused; each font once. Font-level declarations only: ToUnicode contents and every used glyph are not validated; embedded-font extraction gaps remain separate." [[feature]] @@ -8665,7 +8665,7 @@ milestone = "M12" status = "shipped" commands = ["a11y.check"] tools = ["accessibility_check"] -evidence = ["crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +evidence = ["crates/automation/tests/it/automation.rs::accessibility_check_report_and_fixes_through_tools"] notes = "rule scripts: needs manual check" [[feature]] @@ -8881,7 +8881,7 @@ milestone = "M12" status = "partial" commands = ["a11y.autotag"] tools = ["accessibility_autotag_preview", "accessibility_autotag_apply"] -evidence = ["crates/automation/tests/automation.rs::autotag_preview_proposes_text_without_changing_the_pdf", "crates/automation/tests/automation.rs::autotag_apply_writes_reviewable_tags_and_survives_save_reopen", "crates/ui-egui/tests/accessibility.rs::automatic_text_tagging_opens_reviewable_tags"] +evidence = ["crates/automation/tests/it/automation.rs::autotag_preview_proposes_text_without_changing_the_pdf", "crates/automation/tests/it/automation.rs::autotag_apply_writes_reviewable_tags_and_survives_save_reopen", "crates/ui-egui/tests/it/accessibility.rs::automatic_text_tagging_opens_reviewable_tags"] notes = "Auto-tag writes real marked content, a Document structure element, P tags and a parent tree for text-only PDFs, with undo and Tags-panel review. It refuses mixed content, forms, annotations and existing tags. The preview separately proposes headings. Images, tables, lists, links, fields and headings are not classified or tagged by Apply." [[feature]] @@ -8893,7 +8893,7 @@ milestone = "M12" status = "partial" commands = ["a11y.autotag_fields"] tools = ["accessibility_autotag_form_fields"] -evidence = ["crates/a11y/src/tests.rs::autotag_form_fields_writes_widget_structure_and_parent_tree", "crates/automation/tests/automation.rs::autotag_form_fields_writes_reviewable_widget_tags"] +evidence = ["crates/a11y/src/tests.rs::autotag_form_fields_writes_widget_structure_and_parent_tree", "crates/automation/tests/it/automation.rs::autotag_form_fields_writes_reviewable_widget_tags"] notes = "For an untagged AcroForm, writes Form structure elements, OBJR references, widget StructParent keys and ParentTree entries. An existing tree is extended when its root points directly to a Document element and its ParentTree uses a direct Nums array; other layouts are refused before any edit. A widget listed on several pages is tagged once. Text, figures, links and other content remain untagged." [[feature]] @@ -8905,7 +8905,7 @@ milestone = "M12" status = "partial" commands = [] tools = ["accessibility_move_tag"] -evidence = ["crates/a11y/src/tests.rs::sibling_tags_move_in_reading_order_without_changing_content", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +evidence = ["crates/a11y/src/tests.rs::sibling_tags_move_in_reading_order_without_changing_content", "crates/automation/tests/it/automation.rs::accessibility_check_report_and_fixes_through_tools"] notes = "The headless tool can move a reachable indirect tag before or after a direct sibling in their parent's /K array as one undoable edit. It preserves page content and rejects ambiguous children. The visual Reading Order tool, inline tags, arbitrary retagging, and moving across parents remain planned." [[feature]] @@ -8917,7 +8917,7 @@ milestone = "M12" status = "partial" commands = ["a11y.tags"] tools = ["accessibility_tags"] -evidence = ["crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it", "crates/ui-egui/tests/accessibility.rs::checker_finding_opens_its_structure_tag", "crates/automation/tests/automation.rs::structure_tags_are_listed_a_page_at_a_time"] +evidence = ["crates/ui-egui/tests/it/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it", "crates/ui-egui/tests/it/accessibility.rs::checker_finding_opens_its_structure_tag", "crates/automation/tests/it/automation.rs::structure_tags_are_listed_a_page_at_a_time"] notes = "The right-side Tags panel lists reachable indirect tags as a collapsible hierarchy, filters by type or title with ancestors, navigates to their page and edits properties. accessibility_tags lists them a page at a time (offset, limit, next) and can keep only some types, pages or depths. Checker findings linked to indirect tags open the matching tag without losing unsaved edits. Inline tags, adding/deleting tags, artifact marking and finding content remain planned in #982." [[feature]] @@ -8929,7 +8929,7 @@ milestone = "M12" status = "partial" commands = ["a11y.tags"] tools = ["accessibility_tags", "accessibility_set_tag_property"] -evidence = ["crates/a11y/src/tests.rs::reachable_tag_properties_round_trip_and_reject_unrelated_objects", "crates/a11y/src/tests.rs::tag_hierarchy_keeps_indirect_parent_through_inline_tags", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"] +evidence = ["crates/a11y/src/tests.rs::reachable_tag_properties_round_trip_and_reject_unrelated_objects", "crates/a11y/src/tests.rs::tag_hierarchy_keeps_indirect_parent_through_inline_tags", "crates/automation/tests/it/automation.rs::accessibility_check_report_and_fixes_through_tools", "crates/ui-egui/tests/it/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"] notes = "The engine, headless tools and right-side editor list reachable indirect tags with parent and depth and edit type, title, actual text, alternate text and language with undo. Inline tag dictionaries and structural operations remain planned with the Tags Panel (#982)." [[feature]] @@ -8965,7 +8965,7 @@ milestone = "M12" status = "partial" commands = ["a11y.tags"] tools = ["accessibility_move_tag"] -evidence = ["crates/ui-egui/tests/accessibility.rs::tags_panel_moves_a_tag_within_its_siblings", "crates/a11y/src/tests.rs::sibling_tags_move_in_reading_order_without_changing_content", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +evidence = ["crates/ui-egui/tests/it/accessibility.rs::tags_panel_moves_a_tag_within_its_siblings", "crates/a11y/src/tests.rs::sibling_tags_move_in_reading_order_without_changing_content", "crates/automation/tests/it/automation.rs::accessibility_check_report_and_fixes_through_tools"] notes = "The Tags panel moves a selected reachable indirect tag earlier or later among direct siblings, preserving page appearance and making one undoable reading-order edit. It does not yet show page-content order visually, edit direct tag dictionaries, move across parents, or reorder arbitrary content." [[feature]] @@ -8977,7 +8977,7 @@ milestone = "M12" status = "partial" commands = ["a11y.tags"] tools = ["accessibility_tag_content"] -evidence = ["crates/a11y/src/tests.rs::tag_content_lists_direct_marked_content", "crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"] +evidence = ["crates/a11y/src/tests.rs::tag_content_lists_direct_marked_content", "crates/ui-egui/tests/it/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it"] notes = "Selecting a tag in the Tags panel lists its direct marked-content references with MCID, page and extracted text, and selecting an item navigates to the page. The content tree is presented within the Tags panel; editing content objects and arbitrary inline structure nodes remain planned." [[feature]] @@ -9001,7 +9001,7 @@ milestone = "M12" status = "partial" commands = ["a11y.tags"] tools = ["accessibility_mark_tag_artifact"] -evidence = ["crates/a11y/src/tests.rs::marking_a_leaf_tag_as_an_artifact_preserves_appearance_and_structure_integrity", "crates/automation/tests/automation.rs::marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved", "crates/ui-egui/tests/accessibility.rs::tags_panel_marks_a_leaf_tag_as_an_artifact"] +evidence = ["crates/a11y/src/tests.rs::marking_a_leaf_tag_as_an_artifact_preserves_appearance_and_structure_integrity", "crates/automation/tests/it/automation.rs::marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved", "crates/ui-egui/tests/it/accessibility.rs::tags_panel_marks_a_leaf_tag_as_an_artifact"] notes = "Tags panel and automation can retire a reachable indirect leaf tag that owns direct MCIDs: its visible page content remains but becomes /Artifact and the parent-tree references are cleared. Tags with structural children, non-MCID content, missing pages or ambiguous parents are refused. Arbitrary painted-content selection, direct structure dictionaries and removing whole tagged subtrees remain unsupported." [[feature]] @@ -9013,7 +9013,7 @@ milestone = "M12" status = "partial" commands = ["a11y.find_untagged"] tools = ["accessibility_find_untagged"] -evidence = ["crates/automation/tests/automation.rs::finding_untagged_content_limits_results_to_content_annotations_and_widgets", "crates/ui-egui/tests/accessibility.rs::finding_untagged_content_shows_the_focused_accessibility_results"] +evidence = ["crates/automation/tests/it/automation.rs::finding_untagged_content_limits_results_to_content_annotations_and_widgets", "crates/ui-egui/tests/it/accessibility.rs::finding_untagged_content_shows_the_focused_accessibility_results"] notes = "Lists findings for untagged painted page content, non-widget annotations and form widgets, optionally on selected pages. It reuses the checker and does not identify untagged table structure, links already represented by an annotation, or structure-tree elements that lack content." [[feature]] @@ -9037,7 +9037,7 @@ milestone = "M12" status = "shipped" commands = ["a11y.alt_text"] tools = ["accessibility_figures", "accessibility_set_alt"] -evidence = ["crates/a11y/src/tests.rs::figures_are_listed_with_their_alt_text_and_place", "crates/a11y/src/tests.rs::setting_alt_text_and_marking_decorative", "crates/ui-egui/tests/accessibility.rs::setting_alternate_text_figure_by_figure", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +evidence = ["crates/a11y/src/tests.rs::figures_are_listed_with_their_alt_text_and_place", "crates/a11y/src/tests.rs::setting_alt_text_and_marking_decorative", "crates/ui-egui/tests/it/accessibility.rs::setting_alternate_text_figure_by_figure", "crates/automation/tests/it/automation.rs::accessibility_check_report_and_fixes_through_tools"] notes = "Set Alternate Text dialog: each tagged figure (role map honoured) with a preview of where it is drawn, its alternate text, Decorative figure (content becomes /Artifact, element leaves the tags and parent tree), Previous/Next, Save & Close as one undo step" [[feature]] @@ -9061,7 +9061,7 @@ milestone = "M12" status = "shipped" commands = ["file.properties"] tools = ["doc_initial_view"] -evidence = ["crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/automation/tests/automation.rs::initial_view_through_tools", "crates/ui-egui/tests/viewer.rs::initial_view_is_edited_and_honoured_on_open"] +evidence = ["crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open"] notes = "Document Properties ▸ Initial View ▸ Language (catalog /Lang)" [[feature]] @@ -9073,7 +9073,7 @@ milestone = "M12" status = "shipped" commands = ["file.properties"] tools = ["doc_initial_view"] -evidence = ["crates/automation/tests/automation.rs::initial_view_through_tools", "crates/ui-egui/tests/viewer.rs::tab_and_window_show_the_document_title_when_asked"] +evidence = ["crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/ui-egui/tests/it/viewer.rs::tab_and_window_show_the_document_title_when_asked"] notes = "Initial View ▸ Show: Document Title (/DisplayDocTitle); the tab and window then show the title" [[feature]] @@ -9133,7 +9133,7 @@ milestone = "M12" status = "shipped" commands = [] tools = ["view_reading_colors"] -evidence = ["crates/automation/tests/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/reading_colors.rs::reading_colours_seed_on_open", "crates/ui-egui/tests/reading_colors.rs::pending_pages_use_document_reading_background"] +evidence = ["crates/automation/tests/it/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/it/reading_colors.rs::reading_colours_seed_on_open", "crates/ui-egui/tests/it/reading_colors.rs::pending_pages_use_document_reading_background"] notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Pending canvas pages use the configured reading background before their raster arrives (#2059). Closes #897 (duplicate #488)." [[feature]] @@ -9173,7 +9173,7 @@ milestone = "M12" status = "shipped" commands = ["doc.compare"] tools = ["doc_compare", "doc_compare_mark"] -evidence = ["crates/compare/src/lib.rs::insertions_deletions_and_replacements", "crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/automation.rs::comparing_documents_through_tools", "crates/ui-egui/tests/compare.rs::compare_two_versions"] +evidence = ["crates/compare/src/lib.rs::insertions_deletions_and_replacements", "crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/it/automation.rs::comparing_documents_through_tools", "crates/ui-egui/tests/it/compare.rs::compare_two_versions"] notes = "Word-level Myers diff with patience anchors for long files; replaced/inserted/deleted shaded on the new file, listed in the Compare panel, or added as comments." [[feature]] @@ -9185,7 +9185,7 @@ milestone = "M12" status = "shipped" commands = ["doc.compare"] tools = ["doc_compare"] -evidence = ["crates/compare/src/lib.rs::finds_changed_regions", "crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/automation.rs::comparing_documents_through_tools"] +evidence = ["crates/compare/src/lib.rs::finds_changed_regions", "crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/it/automation.rs::comparing_documents_through_tools"] notes = "Pages rendered at 72 dpi and compared page by page; differing regions shaded orange where no text change explains them." [[feature]] @@ -9197,7 +9197,7 @@ milestone = "M12" status = "shipped" commands = ["doc.compare"] tools = [] -evidence = ["crates/ui-egui/tests/compare.rs::compare_two_versions"] +evidence = ["crates/ui-egui/tests/it/compare.rs::compare_two_versions"] notes = "After Compare files the document area splits (when the old version is open in a tab and the window is at least 1000 px wide; the Compare panel's Side by side / New file only toggles it): the old file on the left, the new on the right, each a page view of its own with its shading (deleted and replaced on the old, inserted and replaced on the new, visual differences on both). The half the pointer is over leads: the other takes its zoom, fit, layout, rotation and scroll offset; a change clicked in the panel jumps both halves to their side of it. Clear ends it. Still missing against Acrobat: scrolling by matching content rather than by offset (pages of different sizes drift), and the comparison kept when the old file's tab closes." [[feature]] @@ -9209,7 +9209,7 @@ milestone = "M12" status = "shipped" commands = ["doc.compare"] tools = ["doc_compare_report"] -evidence = ["crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/automation.rs::comparing_documents_through_tools", "crates/ui-egui/tests/compare.rs::compare_two_versions"] +evidence = ["crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/it/automation.rs::comparing_documents_through_tools", "crates/ui-egui/tests/it/compare.rs::compare_two_versions"] notes = "Summary report as a new PDF (counts and every change with pages)." [[feature]] @@ -9221,7 +9221,7 @@ milestone = "M12" status = "partial" commands = ["doc.compare"] tools = [] -evidence = ["crates/ui-egui/tests/compare.rs::compare_filters_hide_text_or_visual_changes"] +evidence = ["crates/ui-egui/tests/it/compare.rs::compare_filters_hide_text_or_visual_changes"] notes = "The Compare panel's Show: Text / Visual checkboxes (both on after Compare files) hide that kind of change on both halves of a side-by-side view and bring it back: Text takes the text changes out of the list and the shading, Visual takes the visual differences out of the shading (the list holds only text changes; visual differences are counted, not listed); hiding the text does not uncover the visual differences a text change explains. The counts, Mark as comments and Report… still cover every change. Still missing against Acrobat's Filter menu: telling images, annotations, formatting and background/header-footer changes apart (the comparison finds text changes and pixel differences only, so those all fall under Visual), and Acrobat's defaults with annotations, formatting and background off. Headless: doc_compare returns every change with its kind, so an agent filters the result itself." [[feature]] @@ -9257,7 +9257,7 @@ milestone = "M13" status = "shipped" commands = ["actions.wizard"] tools = ["action_list", "action_run"] -evidence = ["crates/ui-egui/tests/actions.rs::desktop_actions_preserve_duplicate_names_and_results_from_earlier_runs", "crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"] +evidence = ["crates/ui-egui/tests/it/actions.rs::desktop_actions_preserve_duplicate_names_and_results_from_earlier_runs", "crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/it/automation.rs::actions_through_tools", "crates/ui-egui/tests/it/actions.rs::run_and_create_actions"] notes = "Desktop output filenames receive numbered suffixes when occupied, preserving every batch result and previous outputs. Pick an action and files; results go to a chosen folder, in the background with progress." [[feature]] @@ -9269,7 +9269,7 @@ milestone = "M13" status = "shipped" commands = ["actions.wizard"] tools = ["action_run"] -evidence = ["crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"] +evidence = ["crates/automation/tests/it/automation.rs::actions_through_tools", "crates/ui-egui/tests/it/actions.rs::run_and_create_actions"] notes = "New/Edit/Delete actions with 12 step kinds (OCR, reduce size, remove hidden information, sanitize, flatten, watermark, header, footer, title, detect fields, JavaScript); remembered between runs." [[feature]] @@ -9293,7 +9293,7 @@ milestone = "M13" status = "shipped" commands = ["actions.distribution"] tools = ["action_run"] -evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"] +evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/it/automation.rs::actions_through_tools", "crates/ui-egui/tests/it/actions.rs::run_and_create_actions"] notes = "Remove hidden information, flatten comments and fields." [[feature]] @@ -9305,7 +9305,7 @@ milestone = "M13" status = "shipped" commands = ["actions.optimize_scans"] tools = ["action_run"] -evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools"] +evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/it/automation.rs::actions_through_tools"] notes = "Recognize text, then reduce file size." [[feature]] @@ -9341,7 +9341,7 @@ milestone = "M13" status = "partial" commands = ["actions.wizard", "ocr.recognize_batch"] tools = ["action_run", "ocr_recognize_files", "doc_batch"] -evidence = ["crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions", "crates/automation/tests/batch.rs::batch_recurses_preserves_paths_continues_errors_and_never_replaces_outputs", "apps/pdfcraft-cli/tests/batch.rs::cli_batch_reports_partial_failure_and_continues_into_subfolders"] +evidence = ["crates/automation/tests/it/automation.rs::actions_through_tools", "crates/ui-egui/tests/it/actions.rs::run_and_create_actions", "crates/automation/tests/it/batch.rs::batch_recurses_preserves_paths_continues_errors_and_never_replaces_outputs", "apps/pdfcraft-cli/tests/it/batch.rs::cli_batch_reports_partial_failure_and_continues_into_subfolders"] notes = "Actions and OCR run over chosen files. CLI/MCP doc_batch adds bounded recursive folders for convert/ocr/optimize recipes, retained relative paths, collision-safe outputs and per-file reports. GUI folder selection, generic action recipes, scheduling and resume remain absent. See docs/cli-batch.md." [[feature]] @@ -9401,7 +9401,7 @@ milestone = "M4" status = "shipped" commands = [] tools = ["command_list"] -evidence = ["crates/engine/src/commands.rs::ids_and_shortcuts_are_unique", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented"] +evidence = ["crates/engine/src/commands.rs::ids_and_shortcuts_are_unique", "crates/ui-egui/tests/it/commands.rs::every_registered_command_is_implemented"] notes = "" [[feature]] @@ -9413,7 +9413,7 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/commands.rs::disabled_commands_explain_themselves"] +evidence = ["crates/ui-egui/tests/it/commands.rs::disabled_commands_explain_themselves"] notes = "" [[feature]] @@ -9425,7 +9425,7 @@ milestone = "M2" status = "shipped" commands = [] tools = ["doc_info", "page_render", "text_extract"] -evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find"] +evidence = ["crates/automation/tests/it/automation.rs::open_inspect_render_and_find"] notes = "" [[feature]] @@ -9437,7 +9437,7 @@ milestone = "M4" status = "shipped" commands = [] tools = ["page_rotate", "page_delete", "doc_combine", "page_extract", "doc_split"] -evidence = ["crates/automation/tests/automation.rs::combine_extract_and_split", "crates/automation/tests/automation.rs::edit_undo_redo_and_save_round_trip"] +evidence = ["crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/automation/tests/it/automation.rs::edit_undo_redo_and_save_round_trip"] notes = "" [[feature]] @@ -9449,7 +9449,7 @@ milestone = "M13" status = "shipped" commands = [] tools = [] -evidence = ["crates/automation/tests/automation.rs::tool_table_is_well_formed", "crates/automation/tests/automation.rs::edit_undo_redo_and_save_round_trip", "apps/pdfcraft-cli/tests/stdout.rs::output_commands_exit_cleanly_when_the_reader_has_closed", "apps/pdfcraft-cli/tests/run_report.rs::a_failing_step_is_named_and_the_rest_are_skipped"] +evidence = ["crates/automation/tests/it/automation.rs::tool_table_is_well_formed", "crates/automation/tests/it/automation.rs::edit_undo_redo_and_save_round_trip", "apps/pdfcraft-cli/tests/it/stdout.rs::output_commands_exit_cleanly_when_the_reader_has_closed", "apps/pdfcraft-cli/tests/it/run_report.rs::a_failing_step_is_named_and_the_rest_are_skipped"] notes = "CLI stdout exits cleanly if a pipe reader closes early; other output errors and command failures remain errors. `run --script --report` prints one JSON document with a status per step (#1891); without it the output is unchanged." [[feature]] @@ -9461,7 +9461,7 @@ milestone = "M13" status = "partial" commands = [] tools = [] -evidence = ["crates/automation/tests/automation.rs::command_list_reports_enablement_and_tools"] +evidence = ["crates/automation/tests/it/automation.rs::command_list_reports_enablement_and_tools"] notes = "tools grow as features land" [[feature]] @@ -9473,7 +9473,7 @@ milestone = "M13" status = "shipped" commands = [] tools = ["command_list"] -evidence = ["crates/automation/tests/automation.rs::tool_table_is_well_formed", "crates/automation/tests/automation.rs::errors_are_specific_and_safe"] +evidence = ["crates/automation/tests/it/automation.rs::tool_table_is_well_formed", "crates/automation/tests/it/automation.rs::errors_are_specific_and_safe"] notes = "" [[feature]] @@ -9485,7 +9485,7 @@ milestone = "M13" status = "shipped" commands = [] tools = ["command_list"] -evidence = ["crates/automation/tests/automation.rs::command_list_reports_enablement_and_tools"] +evidence = ["crates/automation/tests/it/automation.rs::command_list_reports_enablement_and_tools"] notes = "" [[feature]] @@ -9497,7 +9497,7 @@ milestone = "M13" status = "shipped" commands = [] tools = ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview"] -evidence = ["crates/automation/tests/automation.rs::mcp_session_over_stdio", "crates/automation/tests/automation.rs::mcp_errors", "crates/automation/tests/automation.rs::conventions_core_tools_preserve_root_and_batch_errors", "crates/automation/tests/automation.rs::conventions_annotations_and_strict_keys_in_both_modes"] +evidence = ["crates/automation/tests/it/automation.rs::mcp_session_over_stdio", "crates/automation/tests/it/automation.rs::mcp_errors", "crates/automation/tests/it/automation.rs::conventions_core_tools_preserve_root_and_batch_errors", "crates/automation/tests/it/automation.rs::conventions_annotations_and_strict_keys_in_both_modes"] notes = "" [[feature]] @@ -9509,7 +9509,7 @@ milestone = "M13" status = "shipped" commands = [] tools = [] -evidence = ["crates/automation/tests/automation.rs::errors_are_specific_and_safe", "crates/automation/tests/automation.rs::root_refusals_do_not_reveal_what_exists_outside", "apps/pdfcraft-cli/tests/run_root.rs::script_image_output_is_confined_to_the_root", "crates/automation/tests/automation.rs::file_names_from_documents_stay_in_the_output_folder"] +evidence = ["crates/automation/tests/it/automation.rs::errors_are_specific_and_safe", "crates/automation/tests/it/automation.rs::root_refusals_do_not_reveal_what_exists_outside", "apps/pdfcraft-cli/tests/it/run_root.rs::script_image_output_is_confined_to_the_root", "crates/automation/tests/it/automation.rs::file_names_from_documents_stay_in_the_output_folder"] notes = "One refusal for every path outside the root, existing or not (#136). `run --script` images (`out`) are confined too (#137). Check-then-write, not openat-style: a process racing the tool on the same disk could swap a folder for a link between the check and the write." [[feature]] @@ -9521,7 +9521,7 @@ milestone = "M13" status = "shipped" commands = [] tools = ["doc_info", "text_extract", "page_render"] -evidence = ["crates/automation/tests/automation.rs::mcp_resources_expose_open_documents", "crates/automation/src/mcp.rs::resource_uris_parse", "crates/automation/tests/automation.rs::conventions_resources"] +evidence = ["crates/automation/tests/it/automation.rs::mcp_resources_expose_open_documents", "crates/automation/src/mcp.rs::resource_uris_parse", "crates/automation/tests/it/automation.rs::conventions_resources"] notes = "resources/list, resources/templates/list and resources/read: pdfcraft://doc/{doc}/info (JSON), /text, /page/{page}/text, /page/{page}/image?dpi= (PNG)" [[feature]] @@ -9533,7 +9533,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/control.rs::commands_keys_and_typing", "crates/ui-egui/tests/control.rs::loopback_transport_requires_the_token"] +evidence = ["crates/ui-egui/tests/it/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/it/control.rs::commands_keys_and_typing", "crates/ui-egui/tests/it/control.rs::loopback_transport_requires_the_token"] notes = "" [[feature]] @@ -9545,7 +9545,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/control.rs::screenshots_of_window_and_region"] +evidence = ["crates/ui-egui/tests/it/control.rs::screenshots_of_window_and_region"] notes = "" [[feature]] @@ -9821,7 +9821,7 @@ milestone = "M14" status = "partial" commands = ["app.preferences"] tools = ["js_enabled"] -evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/ui.rs::preferences_selects_default_workspace_for_next_open", "crates/ui-egui/tests/control.rs::control_default_workspace_and_session_override", "apps/pdfcraft/src/settings.rs::xdg_preferences_use_config_instead_of_data", "apps/pdfcraft/src/settings.rs::migration_preserves_preferences_and_other_data", "crates/ui-egui/tests/preferences_layout.rs::ok_is_on_screen_without_scrolling_at_any_window_height", "crates/ui-egui/tests/preferences_layout.rs::ok_closes_the_dialog_in_a_short_window_and_the_settings_still_scroll"] +evidence = ["crates/ui-egui/tests/it/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/it/ui.rs::preferences_selects_default_workspace_for_next_open", "crates/ui-egui/tests/it/control.rs::control_default_workspace_and_session_override", "apps/pdfcraft/src/settings.rs::xdg_preferences_use_config_instead_of_data", "apps/pdfcraft/src/settings.rs::migration_preserves_preferences_and_other_data", "crates/ui-egui/tests/it/preferences_layout.rs::ok_is_on_screen_without_scrolling_at_any_window_height", "crates/ui-egui/tests/it/preferences_layout.rs::ok_closes_the_dialog_in_a_short_window_and_the_settings_still_scroll"] notes = "Preferences dialog with Interface language, Documents and view (persistent default workspace with session override), Identity, Fill & Sign (flatten on save, off by default) and JavaScript; other categories still live in their tools, and it isn't generated from a schema yet. XDG desktops store app.ron under XDG_CONFIG_HOME/pdfcraft or ~/.config/pdfcraft; existing data-folder preferences are copied without replacing configured preferences, with fallback on migration errors. Logs and recovery retain data locations. Portable/non-XDG locations are unchanged. The settings scroll when the window is short (down to a 60 pt scroll area, so OK stays on screen in the smallest window at the largest interface size) and the OK row stays below them, in reach (#3557); the dialog still has no Cancel, and a window shorter than the dialog's title, one row and OK would still clip it." [[feature]] @@ -9845,7 +9845,7 @@ milestone = "M14" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::preferences_set_the_default_zoom", "crates/ui-egui/tests/view.rs::always_use_my_default_zoom_and_page_layout_ignores_what_the_pdf_asks_for"] +evidence = ["crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::preferences_set_the_default_zoom", "crates/ui-egui/tests/it/view.rs::always_use_my_default_zoom_and_page_layout_ignores_what_the_pdf_asks_for"] notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it, unless Always use my default zoom and page layout is on. Missing: Acrobat's resolution, rendering and page-content options" [[feature]] @@ -9965,7 +9965,7 @@ milestone = "M14" status = "partial" commands = ["app.preferences"] tools = [] -evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] +evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic/Norwegian Bokmål and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar|nb and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Brazilian Portuguese covers the current union of catalog keys, including signature and accessibility messages; Czech remains limited to menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one, and Norwegian locales (nb, nb_NO, and no/nn until a Nynorsk catalog exists) the Bokmål one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish, Italian and Brazilian Portuguese translate them, Polish the signature details and the changes after signing; strings absent from other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), the engine's sentences in the other catalogs, a full localization audit." [[feature]] @@ -10013,7 +10013,7 @@ milestone = "M14" status = "shipped" commands = ["app.preferences"] tools = ["js_enabled"] -evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences"] +evidence = ["crates/ui-egui/tests/it/javascript.rs::document_scripts_and_preferences"] notes = "Enable Acrobat JavaScript, remembered between runs." [[feature]] From a08d974ac14bbaa37b34e5b1ccf728eb89b378ef Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:12:45 +0000 Subject: [PATCH 004/142] fix(cos): encrypt the /Contents of an annotation that carries a /ByteRange Saving with encryption left a dictionary's /Contents in clear whenever it had a string /Contents and an array /ByteRange, whatever else it was. A source file could add a /ByteRange to an ordinary annotation and its comment text was then written unencrypted. A dictionary with a /Subtype or a /Type other than Sig or DocTimeStamp is never a signature dictionary, so its /Contents is encrypted like any other string. --- crates/cos/src/security.rs | 13 ++++++++++--- crates/cos/tests/it/encryption.rs | 32 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index cd0d04a14..67d95aa7a 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -88,10 +88,17 @@ fn named_crypt_filter(dict: &Dict) -> Option> { /// whose `/Contents` is never encrypted (ISO 32000-2 §7.6.2, §12.8.1): the signer writes a /// placeholder, hashes the file around it and patches the value in place, so encrypting it /// would make signing impossible. Identified as other readers do: by `/Type`, or by a string -/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). +/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). A signature dictionary +/// has no `/Subtype`; a dictionary with one (an annotation, whose `/Contents` is the comment +/// text) or with another `/Type` is never exempt, whatever else it carries. fn is_signature_dict(d: &Dict) -> bool { - matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")) - || (matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_)))) + if d.get(b"Subtype").is_some() { + return false; + } + match d.get(b"Type") { + Some(_) => matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")), + None => matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_))), + } } /// Decrypt (`decrypt = true`) or encrypt every string and stream in an indirect object, diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index e05161558..dd2dcb1a4 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -279,6 +279,38 @@ fn signature_contents_stay_unencrypted() { } } +/// Only a signature dictionary keeps its `/Contents` in clear. An annotation that carries a +/// `/ByteRange` (or even `/Type /Sig`) is still an annotation, and its comment is encrypted. +#[test] +fn a_byte_range_does_not_exempt_an_annotation() { + for (ty, subtype) in [(Some("Annot"), "Text"), (None, "FreeText"), (Some("Sig"), "Text")] { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let mut note = pdfcraft_cos::Dict::new(); + if let Some(ty) = ty { + note.set(b"Type".to_vec(), Object::name(ty)); + } + note.set(b"Subtype".to_vec(), Object::name(subtype)); + note.set(b"Contents".to_vec(), PdfString::literal(b"Private note".to_vec())); + note.set(b"ByteRange".to_vec(), Object::Array(Vec::new())); + let note = doc.add(Object::Dict(note)); + doc.update_dict(doc.root().unwrap(), |c| c.set(b"Test".to_vec(), Object::Ref(note))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("Private note"), "{ty:?} {subtype}: comment encrypted"); + let back = Document::open(Arc::new(bytes)).unwrap(); + let note = back.get(back.root().unwrap()).as_dict().unwrap().reference(b"Test").unwrap(); + assert_eq!(back.get(note).as_dict().unwrap().get(b"Contents").unwrap().as_string().unwrap().bytes, b"Private note"); + } +} + #[test] fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() { // A V4 crypt filter's /Length counts bytes (≤ 32) or bits. A zero or negative value is From 60d2d1e3f21729befdd70cc734a3fca2ee09480c Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:12:57 +0000 Subject: [PATCH 005/142] fix(cos): put inherited crypt-filter selectors under the new protection A stream that named its own crypt filter (/Filter /Crypt with /DecodeParms /Name) kept that name when the document was given new password protection. The new /Encrypt defines only /StdCF, so the inherited name resolved to /Identity and the stream was written in clear into a file the user asked to protect. When saving with protection applied by set_encryption, a selector the new handler does not define is removed with its parameters and the stream follows the new policy. Saving with the original handler keeps the source's selectors. --- crates/cos/src/document.rs | 2 +- crates/cos/src/security.rs | 75 ++++++++++++++++++++++++++----- crates/cos/src/writer.rs | 5 ++- crates/cos/tests/it/encryption.rs | 44 +++++++++++++++++- 4 files changed, 113 insertions(+), 13 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index ae00234d0..c3f6c6b74 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -858,7 +858,7 @@ impl Document { /// Decrypt a freshly parsed indirect object if the document is encrypted. fn decrypted(&self, id: ObjRef, o: Object) -> Object { match &self.security { - Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, true), + Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, crate::security::Pass::Decrypt), _ => o, } } diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index 67d95aa7a..12d08b8dc 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -101,36 +101,91 @@ fn is_signature_dict(d: &Dict) -> bool { } } -/// Decrypt (`decrypt = true`) or encrypt every string and stream in an indirect object, -/// except a signature dictionary's `/Contents` (see [`is_signature_dict`]). -pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, decrypt: bool) -> Object { +/// Remove the `/Crypt` entry from a stream's filter chain, with its decode parameters. +fn drop_crypt_filter(dict: &mut Dict) { + let i = match dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().position(|n| n.as_name() == Some(b"Crypt")), + Some(Object::Name(n)) if n.as_slice() == b"Crypt" => Some(0), + _ => None, + }; + let Some(i) = i else { return }; + let mut filters = match dict.get(b"Filter") { + Some(Object::Array(a)) => a.clone(), + _ => Vec::new(), + }; + if i < filters.len() { + filters.remove(i); + } + // A lone parameter dictionary belongs to the first filter (as `named_crypt_filter` reads it). + match dict.get(b"DecodeParms").cloned() { + Some(Object::Array(mut parms)) if i < parms.len() => { + parms.remove(i); + dict.set(b"DecodeParms".to_vec(), Object::Array(parms)); + } + Some(Object::Dict(_)) if i == 0 => { + dict.remove(b"DecodeParms"); + } + _ => {} + } + if filters.is_empty() { + dict.remove(b"Filter"); + if matches!(dict.get(b"DecodeParms"), Some(Object::Array(p)) if p.is_empty()) { + dict.remove(b"DecodeParms"); + } + } else { + dict.set(b"Filter".to_vec(), Object::Array(filters)); + } +} + +/// What [`transform`] does to an object. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Pass { + /// Decrypt, with the handler the document was opened with. + Decrypt, + /// Encrypt with the handler the document was opened with: streams keep their crypt filters. + Encrypt, + /// Encrypt with protection applied by `set_encryption`. A stream's crypt filter came from + /// the source's `/Encrypt`; unless the new one defines that name it is dropped, so the + /// stream follows the new policy instead of reading as `/Identity` and staying in clear. + EncryptReplacing, +} + +/// Decrypt or encrypt every string and stream in an indirect object, except a signature +/// dictionary's `/Contents` (see [`is_signature_dict`]). +pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, pass: Pass) -> Object { + let decrypt = pass == Pass::Decrypt; let strings = |s: &PdfString| { let bytes = if decrypt { h.decrypt_string(num, generation, &s.bytes) } else { h.encrypt_string(num, generation, &s.bytes) }; PdfString { bytes, hex: s.hex || !decrypt } }; - walk(o, &strings, h, num, generation, decrypt) + walk(o, &strings, h, num, generation, pass) } -fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, decrypt: bool) -> Object { +fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, pass: Pass) -> Object { + let decrypt = pass == Pass::Decrypt; match o { Object::String(s) => Object::String(strings(s)), - Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, decrypt)).collect()), + Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, pass)).collect()), Object::Dict(d) if is_signature_dict(d) => Object::Dict( d.iter() .map(|(k, v)| match (k.as_slice(), v) { (b"Contents", Object::String(_)) => (k.clone(), v.clone()), - _ => (k.clone(), walk(v, strings, h, num, generation, decrypt)), + _ => (k.clone(), walk(v, strings, h, num, generation, pass)), }) .collect(), ), - Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect()), + Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect()), Object::Stream(s) => { - let dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect(); + let mut dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect(); let ty = s.dict.name(b"Type"); if ty == Some(b"XRef") || (ty == Some(b"Metadata") && !h.encrypts_metadata()) { return Object::Stream(Stream { dict, raw: s.raw.clone() }); } - let named = named_crypt_filter(&s.dict); + let mut named = named_crypt_filter(&s.dict); + if pass == Pass::EncryptReplacing && named.as_deref().is_some_and(|n| !h.dict().crypt_filters.iter().any(|(f, _)| f == n)) { + drop_crypt_filter(&mut dict); + named = None; + } let kind = match (&named, ty) { (Some(n), _) => StreamKind::Named(n), (None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile, diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 3db0566ff..b151399fe 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -132,7 +132,10 @@ fn write_stream(s: &Stream, out: &mut Vec) { /// except the `/Encrypt` dictionary itself (`source_num` is its number in the source document). fn prepared<'a>(doc: &Document, source_num: u32, num: u32, generation: u16, o: &'a Object) -> Cow<'a, Object> { match doc.output_security() { - (Some(h), encrypt_num) if Some(source_num) != encrypt_num => Cow::Owned(crate::security::transform(h, o, num, generation, false)), + (Some(h), encrypt_num) if Some(source_num) != encrypt_num => { + let pass = if doc.encryption_changed() { crate::security::Pass::EncryptReplacing } else { crate::security::Pass::Encrypt }; + Cow::Owned(crate::security::transform(h, o, num, generation, pass)) + } _ => Cow::Borrowed(o), } } diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index dd2dcb1a4..b0688b930 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -9,11 +9,16 @@ use pdfcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, O const SECRET: &str = "BT /F1 18 Tf 20 100 Td (Secret page) Tj ET"; fn fixture() -> Vec { + fixture_with("") +} + +/// The fixture with `extra` added to the content stream's dictionary. +fn fixture_with(extra: &str) -> Vec { let objs = [ "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_string(), "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), - format!("<< /Length {} >>\nstream\n{SECRET}\nendstream", SECRET.len()), + format!("<< /Length {}{extra} >>\nstream\n{SECRET}\nendstream", SECRET.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), "<< /Type /Metadata /Subtype /XML /Length 22 >>\nstream\nmeta\nendstream".replace("22", "26"), "<< /Title (Quarterly numbers) >>".to_string(), @@ -311,6 +316,43 @@ fn a_byte_range_does_not_exempt_an_annotation() { } } +/// Protecting a document puts every stream under the new handler. A stream that selects a +/// crypt filter of its own loses the selector: the new `/Encrypt` defines only `/StdCF`, so an +/// inherited name (or `/Identity`) would leave the stream in clear. +#[test] +fn protecting_drops_inherited_crypt_filter_selectors() { + for selector in [" /Filter /Crypt /DecodeParms << /Name /MyCF >>", " /Filter [/Crypt] /DecodeParms [<< /Name /Identity >>]", " /Filter /Crypt"] { + let mut doc = Document::open(Arc::new(fixture_with(selector))).unwrap(); + assert_eq!(content(&doc), SECRET, "{selector}"); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("Secret page"), "{selector}: stream encrypted"); + assert_eq!(content(&Document::open(Arc::new(bytes.clone())).unwrap()), SECRET, "{selector}"); + assert!(hayro_syntax::Pdf::new(bytes).is_ok(), "{selector}"); + } + // Under the handler the document was opened with, `/Identity` is the author's choice (a + // stream meant to stay readable) and is kept. + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let pages = doc.get(catalog.reference(b"Pages").unwrap()).as_dict().cloned().unwrap(); + let page = doc.resolve(&pages.get(b"Kids").unwrap().as_array().unwrap()[0]).as_dict().cloned().unwrap(); + let contents = page.reference(b"Contents").unwrap(); + let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") }; + s.dict.set(b"Filter".to_vec(), Object::name("Crypt")); + doc.set(contents, Object::Stream(s)); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(String::from_utf8_lossy(&saved).contains("Secret page"), "written as the stream asks"); + assert_eq!(content(&Document::open(Arc::new(saved)).unwrap()), SECRET); +} + #[test] fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() { // A V4 crypt filter's /Length counts bytes (≤ 32) or bits. A zero or negative value is From 7fb6ab0f1fd731efe8eda5e04fd4e35b320ba20a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:13:03 +0000 Subject: [PATCH 006/142] test(cos): write the qpdf oracle files into a private new directory The qpdf oracle test wrote into temp_dir()/pdfcraft-enc-, reusing the directory if it existed and following any symlink inside it, so another local account could prepare it and have the test overwrite a file of the developer's. The directory now has an unpredictable name, is created by the test (mode 0700 on Unix) and the files are created new. --- crates/cos/tests/it/encryption.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index b0688b930..8d03d2279 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -2,6 +2,7 @@ //! with our reader and with an independent one (hayro-syntax's decryption). Also editing //! encrypted documents incrementally, changing and removing passwords, and permissions. +use std::io::Write; use std::sync::Arc; use pdfcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, Object, PdfString, SaveOptions, write_full, write_incremental}; @@ -214,11 +215,10 @@ fn unencrypted_metadata_is_left_readable() { #[test] fn qpdf_agrees() { let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); - let dir = std::env::temp_dir().join(format!("pdfcraft-enc-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let dir = private_temp_dir("pdfcraft-enc"); for alg in ALL { let path = dir.join(format!("{alg:?}.pdf")); - std::fs::write(&path, protect(alg, "user", "owner", -1)).unwrap(); + std::fs::File::create_new(&path).and_then(|mut f| f.write_all(&protect(alg, "user", "owner", -1))).unwrap(); for (pw, expect) in [("user", "user password"), ("owner", "owner password")] { let out = qpdf.command().arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap(); let text = String::from_utf8_lossy(&out.stdout); @@ -230,6 +230,30 @@ fn qpdf_agrees() { let _ = std::fs::remove_dir_all(dir); } +/// A new directory for oracle files, private to this user: an unpredictable name (`RandomState` +/// is seeded by the operating system), created by this call or not at all. +fn private_temp_dir(prefix: &str) -> std::path::PathBuf { + use std::hash::{BuildHasher, Hasher}; + let mut name = std::collections::hash_map::RandomState::new().build_hasher(); + name.write_u32(std::process::id()); + let dir = std::env::temp_dir().join(format!("{prefix}-{:016x}", name.finish())); + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder.create(&dir).unwrap(); + dir +} + +/// Oracle files go in a directory this test created: another local account cannot have +/// prepared it (or symlinks inside it) in advance. +#[test] +fn oracle_files_go_in_a_fresh_directory() { + let (a, b) = (private_temp_dir("pdfcraft-enc-probe"), private_temp_dir("pdfcraft-enc-probe")); + assert_ne!(a, b); + assert_eq!(std::fs::read_dir(&a).unwrap().count(), 0); + let _ = (std::fs::remove_dir(a), std::fs::remove_dir(b)); +} + /// A signature dictionary's `/Contents` is never encrypted (§7.6.2): signers patch it in place /// after hashing the file around it. Its other strings, and the `/Contents` of anything that /// is not a signature dictionary, are encrypted as usual, and reading leaves it untouched. From b72c730a951352cd427c24d0b11cfba9a17b00e0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:13:36 +0000 Subject: [PATCH 007/142] fix(cos): give the protect example a fresh key on every run The example passed a constant seed to set_encryption. AES-256 derives the file key from the seed alone, so anyone could rebuild the key and read a file it protected without either password. The seed now comes from the operating system's randomness through RandomState, as in the engine. --- crates/cos/examples/protect.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/cos/examples/protect.rs b/crates/cos/examples/protect.rs index 6f27a6096..48498aefa 100644 --- a/crates/cos/examples/protect.rs +++ b/crates/cos/examples/protect.rs @@ -1,4 +1,17 @@ //! Encrypt a PDF: `cargo run -p pdfcraft-cos --example protect in.pdf out.pdf rc4-40|rc4-128|aes-128|aes-256 USER OWNER` +/// 32 fresh bytes for the file key. `RandomState` is seeded by the operating system (as the +/// engine's `entropy` uses it), so every run encrypts with a different key. +fn seed() -> [u8; 32] { + use std::hash::{BuildHasher, Hasher}; + let mut out = [0u8; 32]; + for (i, chunk) in out.chunks_mut(8).enumerate() { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_usize(i); + chunk.copy_from_slice(&h.finish().to_le_bytes()); + } + out +} + fn main() { let a: Vec = std::env::args().skip(1).collect(); let [input, output, alg, user, owner] = &a[..] else { panic!("usage: protect in out alg user owner") }; @@ -15,7 +28,7 @@ fn main() { owner_password: owner, permissions: -1, encrypt_metadata: true, - seed: [1; 32], + seed: seed(), }) .unwrap(); std::fs::write(output, pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()).unwrap(); From faab42d4058ff70588d22a1ac82e7b2b27b375e3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:58 +0000 Subject: [PATCH 008/142] fix(sign): authorize an embedded OCSP responder by signature, not by name An OCSP response's signer counted as the issuer itself whenever its certificate carried the issuer's subject name. A response naming its responder by key hash could embed a certificate for any key with the issuer's name copied in, skip the delegation checks, and mark a valid certificate revoked. Whether the responder is the issuer now follows from how it was selected (the issuer's own name or key hash); every embedded certificate must be signed by the issuer and carry the OCSP-signing purpose. --- crates/sign/src/revocation.rs | 15 ++++++------ crates/sign/tests/it/crypto.rs | 43 ++++++++++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/crates/sign/src/revocation.rs b/crates/sign/src/revocation.rs index 8f9dac0cd..b4a9d820f 100644 --- a/crates/sign/src/revocation.rs +++ b/crates/sign/src/revocation.rs @@ -259,26 +259,27 @@ impl OcspResponse { /// response window. pub fn check(&self, cert: &Certificate, issuer: &Certificate, at: Time) -> RevocationStatus { // Who signed the response? The issuer identified by responderID, or a delegated, - // embedded responder identified the same way. - let responder: Option<&Certificate> = match &self.responder_id { + // embedded responder identified the same way. Which of the two it is comes from that + // choice, never from the embedded certificate's names, which anyone can copy. + let (responder, delegated): (Option<&Certificate>, bool) = match &self.responder_id { ResponderId::ByName(name) => { if *name == issuer.subject.raw { - Some(issuer) + (Some(issuer), false) } else { - self.responder_cert.as_ref().filter(|c| *name == c.subject.raw) + (self.responder_cert.as_ref().filter(|c| *name == c.subject.raw), true) } } ResponderId::ByKey(hash) => { if *hash == DigestAlg::Sha1.digest(&[&issuer.public_key.key_bits()]) { - Some(issuer) + (Some(issuer), false) } else { - self.responder_cert.as_ref().filter(|c| *hash == DigestAlg::Sha1.digest(&[&c.public_key.key_bits()])) + (self.responder_cert.as_ref().filter(|c| *hash == DigestAlg::Sha1.digest(&[&c.public_key.key_bits()])), true) } } }; let Some(responder) = responder else { return RevocationStatus::Unknown }; // A delegated responder must carry the OCSP-signing EKU and be issued by the issuer. - if responder.subject.raw != issuer.subject.raw { + if delegated { if responder.issuer.raw != issuer.subject.raw || !responder.signed_by(&issuer.public_key) { return RevocationStatus::Unknown; } diff --git a/crates/sign/tests/it/crypto.rs b/crates/sign/tests/it/crypto.rs index cd36c5277..193fd06c6 100644 --- a/crates/sign/tests/it/crypto.rs +++ b/crates/sign/tests/it/crypto.rs @@ -355,10 +355,19 @@ fn ocsp(issuer: &pkcs12::DigitalId, status: RevocationStatus, this: Time, next: }; let single = der::seq(&[&cert_id, &status, &this.encode(), &der::tlv(tag::ctx(0), &next.encode())]); let responder_id = der::tlv(tag::ctx(1), &issuer.certificate.subject.raw); - let tbs = der::seq(&[&responder_id, &this.encode(), &der::seq(&[&single])]); - let alg = issuer.key.signature_algorithm(DigestAlg::Sha256); - let sig = issuer.key.sign(DigestAlg::Sha256, &tbs).unwrap(); - let basic = der::seq(&[&tbs, &alg, &der::bit_string(&sig)]); + signed_ocsp(&issuer.key, &responder_id, None, &single, this) +} + +/// A successful OCSP response holding `single`, signed with `key` and naming its responder by +/// `responder_id`, with `certs` (the encoded `[0]` field) if given. +fn signed_ocsp(key: &pdfcraft_sign::PrivateKey, responder_id: &[u8], certs: Option<&[u8]>, single: &[u8], at: Time) -> Vec { + let tbs = der::seq(&[responder_id, &at.encode(), &der::seq(&[single])]); + let alg = key.signature_algorithm(DigestAlg::Sha256); + let sig = key.sign(DigestAlg::Sha256, &tbs).unwrap(); + let basic = match certs { + Some(c) => der::seq(&[&tbs, &alg, &der::bit_string(&sig), c]), + None => der::seq(&[&tbs, &alg, &der::bit_string(&sig)]), + }; let basic_type = der::oid("1.3.6.1.5.5.7.48.1.1"); der::seq(&[&der::int(0), &der::explicit(0, &der::seq(&[&basic_type, &der::octets(&basic)]))]) } @@ -465,6 +474,32 @@ fn ocsp_responses_verify_and_match_the_certificate() { assert!(OcspResponse::parse(&error).is_err()); } +/// An embedded responder certificate answers for an issuer only when the issuer signed it for +/// OCSP signing. One that merely copies the issuer's name, found by its key hash, does not. +#[test] +fn a_responder_that_copies_the_issuers_name_does_not_answer_for_it() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let issuer = &id.certificate; + let (this, next) = (mid_june(), Time { year: 2026, month: 7, day: 15, hour: 12, minute: 0, second: 0 }); + let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap(); + let impostor = Certificate::self_signed(&issuer.subject, &key, Time { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, 5, &[9]).unwrap(); + assert_eq!(impostor.subject.raw, issuer.subject.raw); + let cert_id = der::seq(&[ + &DigestAlg::Sha1.algorithm(), + &der::octets(&DigestAlg::Sha1.digest(&[&issuer.issuer.raw])), + &der::octets(&DigestAlg::Sha1.digest(&[&issuer.public_key.key_bits()])), + &der::uint(&issuer.serial), + ]); + let revoked = der::tlv(tag::ctx(1), &Time { year: 2026, month: 6, day: 2, hour: 9, minute: 0, second: 0 }.encode()); + let single = der::seq(&[&cert_id, &revoked, &this.encode(), &der::tlv(tag::ctx(0), &next.encode())]); + let by_key = der::tlv(tag::ctx(2), &der::octets(&DigestAlg::Sha1.digest(&[&impostor.public_key.key_bits()]))); + // RFC 6960's `[0] EXPLICIT SEQUENCE OF Certificate`, and the certificate directly in `[0]`. + for certs in [der::explicit(0, &der::seq(&[&impostor.raw])), der::explicit(0, &impostor.raw)] { + let forged = OcspResponse::parse(&signed_ocsp(&key, &by_key, Some(&certs), &single, this)).unwrap(); + assert_eq!(forged.check(issuer, issuer, mid_june()), RevocationStatus::Unknown); + } +} + // ── tolerance for the encodings found in old and unusual signers ─────────────────────────────── // RSA signatures over SHA-256("hello") by the key in rsa-aes.p12, made with OpenSSL 3 (see // tests/data/README.md): the standard DigestInfo, DigestInfo without the NULL parameter, the bare From fbada004d32d4fbb24638b2fe6eb27c0b9637f49 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:38:04 +0000 Subject: [PATCH 009/142] fix(sign): treat a changed trailer /Root after signing as not permitted Revision checks compared objects only. A later revision that pointed the trailer at another catalog already present in the signed file changed no object, so the signature stayed valid while the reader showed a different document. A /Root that differs from the signed revision's is now a disallowed change to the document structure. --- crates/sign/src/pdf.rs | 5 +++++ crates/sign/tests/it/pdf.rs | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 57c9f95fd..50f5d4a19 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1286,6 +1286,11 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod disallowed.push(what); } } + // The trailer chooses the catalog. A later revision that points it at another catalog (one + // already in the signed file, so no object changes) shows another document. + if doc.root() != old.root() { + disallowed.push(kind::STRUCTURE); + } for num in doc.object_numbers() { if same_place(num) { continue; diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index e9db0b570..29f61305f 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -2471,3 +2471,27 @@ fn rewritten_page_resources_are_page_content_whatever_keys_they_carry() { s.modification ); } + +/// A later revision that only points the trailer at another catalog already in the signed file +/// shows a different document while every signed object is unchanged. That is not permitted. +#[test] +fn switching_the_trailer_to_another_signed_catalog_is_not_permitted() { + let base = fixture_with( + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", + &[ + b"<< /Type /Catalog /Pages 8 0 R /AcroForm << /Fields [6 0 R] >> >>", + b"<< /Type /Pages /Kids [9 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 8 0 R /MediaBox [0 0 300 300] /Contents 10 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>", + b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract void) Tj ET\nendstream", + ], + ); + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + let signed = pdfcraft_sign::sign(&open(&base), &id, &SignOptions { field: Some("Approval".into()), rect: None, ..opts() }).unwrap(); + let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Valid, "{:?}", s.details); + let switched = edit_after(&signed, |doc| doc.trailer_mut().set(b"Root".to_vec(), oref(7))); + let s = signatures(&open(&switched), &switched, &trust).into_iter().find(|s| s.signed).expect("the signature is still listed"); + assert!(matches!(s.modification, Modification::Disallowed(_)), "{:?}", s.modification); + assert_eq!(s.status, Status::Invalid, "{:?}", s.details); +} From d9cb7c2fe7e48708a65b4fbd8ef0b87245d42e8f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:38:19 +0000 Subject: [PATCH 010/142] fix(sign): don't build chains through a CA with unprocessed critical extensions Chain building accepted an intermediate CA without looking at its critical extensions, which were checked only on the signer's certificate. A subordinate CA limited by critical name constraints could vouch for a signer outside its namespace, because PdfCraft does not enforce those constraints. A CA certificate (other than a self-signed root, whose limits are the trust store's business) with a critical extension PdfCraft doesn't process is refused as an issuer, and the reason is reported. --- crates/sign/src/pdf.rs | 1 + crates/sign/src/x509.rs | 7 ++++++- crates/sign/tests/it/chain.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 50f5d4a19..ef2715f83 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -378,6 +378,7 @@ pub const WORDINGS: &[&str] = &[ "The signer's certificate has critical extensions PdfCraft does not recognize ({}).", // Why a certificate that matched the issuer was not used to build the chain. "The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}.", + "The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}.", "The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}.", "The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}.", "The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates.", diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 53c80e269..56ec8fbdd 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -492,7 +492,8 @@ pub fn subject_of(raw: &[u8]) -> Option { } /// The chain from `leaf` up through `pool`, as far as issuers can be found and may issue: each -/// issuer's key verifies the certificate below it, it is a CA allowed to sign certificates, its +/// issuer's key verifies the certificate below it, it is a CA allowed to sign certificates, it +/// has no critical extension PdfCraft doesn't process (unless it is self-signed), its /// `pathLenConstraint` allows the CA certificates below it, and (when `at` is given) it was valid /// then. Stops at a self-signed certificate. Certificates embedded in a document are in `pool` /// too, so an ordinary end-entity certificate must never be accepted as an issuer. @@ -522,6 +523,10 @@ pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at: // two names where it needs them. let why = if !c.may_issue() { Some("The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}.") + } else if !c.unknown_critical.is_empty() && !c.is_self_signed() { + // What such an extension limits (name constraints, policy constraints) would + // go unchecked. A self-signed root's extensions are the trust store's business. + Some("The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}.") } else if c.path_len.is_some_and(|n| below > n as usize) { Some("The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}.") } else if at.is_some_and(|t| !c.valid_at(t)) { diff --git a/crates/sign/tests/it/chain.rs b/crates/sign/tests/it/chain.rs index 5defd859d..2b6b5f85a 100644 --- a/crates/sign/tests/it/chain.rs +++ b/crates/sign/tests/it/chain.rs @@ -340,3 +340,23 @@ fn an_unreadable_extended_key_usage_allows_no_purpose() { verdict(signer_with("Good EKU", &r, &[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["2.5.29.37.0"])]), vec![], &r.cert); assert_eq!(status, Status::Valid, "{details:?}"); } + +/// A CA certificate with a critical extension PdfCraft does not enforce (here name constraints) +/// vouches for nothing: what that extension limits would go unchecked. A self-signed root's +/// extensions are the trust store's business, so such a root still anchors. +#[test] +fn an_issuer_with_an_unprocessed_critical_extension_does_not_vouch() { + let name_constraints = ext("2.5.29.30", &der::seq(&[])); + let r = root(); + let constrained = issue("Name Constrained CA", Some(&r), FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints.clone()]); + let l = end_entity("Outside Its Names", &constrained); + let pool = [constrained.cert.clone(), r.cert.clone()]; + let (chain, why) = build_chain_noted(&l.cert, &pool, None); + assert_eq!(names(&chain), ["Outside Its Names"]); + assert!(why.is_some_and(|w| w.message.contains("critical extensions")), "the reason is reported"); + let constrained_root = issue("Name Constrained Root", None, FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints]); + let i = ca("Under Constrained Root", &constrained_root, None); + let l = end_entity("Leaf", &i); + let pool = [i.cert.clone(), constrained_root.cert.clone()]; + assert_eq!(names(&build_chain(&l.cert, &pool, None)), ["Leaf", "Under Constrained Root", "Name Constrained Root"]); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index db00c69dd..1d121c59b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2359,6 +2359,7 @@ action wizard Start Iniciar The signer's certificate has critical extensions PdfCraft does not recognize ({}). El certificado del firmante tiene extensiones críticas que PdfCraft no reconoce ({}). # Why a certificate that matched the issuer was not used to build the chain. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. El certificado de {} no es un certificado de CA que pueda emitir certificados, así que no se usa para avalar a {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. El certificado de {} tiene extensiones críticas que PdfCraft no reconoce, así que no se usa para avalar a {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. El certificado de {} no admite tantos certificados de CA por debajo (longitud de ruta), así que no se usa para avalar a {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. El certificado de {} no era válido en el momento de la firma, así que no se usa para avalar a {}. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. La identidad del firmante es desconocida porque no está en tu lista de certificados de confianza y ninguno de los certificados superiores es de confianza. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cd381b539..8176e498b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2503,6 +2503,7 @@ animation effects Off Wyłączone The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Certyfikat podpisującego nie jest wystawiony do podpisywania dokumentów (jego rozszerzone użycie klucza nie obejmuje podpisywania dokumentów). The signer's certificate has critical extensions PdfCraft does not recognize ({}). Certyfikat podpisującego ma rozszerzenia krytyczne, których PdfCraft nie rozpoznaje ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Certyfikat podmiotu {} nie jest certyfikatem urzędu certyfikacji, który może wystawiać certyfikaty, dlatego nie poświadcza certyfikatu {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Certyfikat podmiotu {} ma rozszerzenia krytyczne, których PdfCraft nie rozpoznaje, dlatego nie poświadcza certyfikatu {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Certyfikat podmiotu {} nie dopuszcza tylu certyfikatów urzędów certyfikacji poniżej siebie (długość ścieżki), dlatego nie poświadcza certyfikatu {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Certyfikat podmiotu {} nie był ważny w chwili podpisania, dlatego nie poświadcza certyfikatu {}. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Tożsamość podpisującego jest nieznana, ponieważ jego certyfikatu nie ma na liście zaufanych certyfikatów i żaden z certyfikatów nadrzędnych nie jest zaufany. From 4e9d8f9b3b8f9f6403bdcb9145d8290ab9a58537 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:38:19 +0000 Subject: [PATCH 011/142] fix(sign): require the time-stamping purpose for trusted timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A timestamp counted as trusted time whenever its signer's certificate chained to a trusted certificate, whatever the certificate was issued for. Any key from a trusted CA could then vouch for an arbitrary time. Trusted time, for a document timestamp and for a signature's embedded one, now also needs a certificate whose extended key usage includes timeStamping, whose key usage allows signing, and which has no critical extension PdfCraft doesn't process (RFC 3161 §2.3). The test timestamp authority gets such a certificate. --- crates/sign/src/pdf.rs | 23 +++++++---- crates/sign/src/x509.rs | 15 ++++++++ crates/sign/tests/it/pdf.rs | 70 +++++++++++++++++++++++++++++----- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + 5 files changed, 94 insertions(+), 16 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index ef2715f83..82a801dee 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -401,6 +401,7 @@ pub const WORDINGS: &[&str] = &[ "The timestamp token could not be read ({}).", "The document has been altered or corrupted since the timestamp was applied.", "The timestamp authority's certificate was not valid at the time of timestamping.", + "The timestamp authority's certificate is not issued for time stamping.", "This document has not been modified since this timestamp was applied.", "The document has been modified since this timestamp was applied, but the changes are permitted ({}).", "The document has been altered since this timestamp was applied in ways it does not permit ({}).", @@ -771,13 +772,16 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf // own claimed time, as without a timestamp. let mut pool = crate::timestamp::token_certs(raw); pool.extend(trust.certs.iter().cloned()); - let trusted_tsa = t - .signer_certificate() - .is_some_and(|c| c.valid_at(t.gen_time) && build_chain(&c, &pool, Some(t.gen_time)).iter().any(|x| trust.trusts(x))); - if trusted_tsa { + let tsa = t.signer_certificate(); + let trusted_tsa = tsa + .as_ref() + .is_some_and(|c| c.valid_at(t.gen_time) && build_chain(c, &pool, Some(t.gen_time)).iter().any(|x| trust.trusts(x))); + if !trusted_tsa { + unverified_time = Some(t.gen_time); + } else if tsa.as_ref().is_some_and(Certificate::may_timestamp) { info.timestamp_time = Some(t.gen_time); } else { - unverified_time = Some(t.gen_time); + info.details.push(Detail::plain("The timestamp authority's certificate is not issued for time stamping.")); } } } @@ -1085,6 +1089,10 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig info.status = Status::Unknown; info.details.push(Detail::plain("The timestamp authority's certificate was not valid at the time of timestamping.")); } + if !cert.may_timestamp() { + info.status = Status::Unknown; + info.details.push(Detail::plain("The timestamp authority's certificate is not issued for time stamping.")); + } } info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) { Modification::None @@ -1112,9 +1120,10 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig pool.extend(trust.certs.iter().cloned()); // A trusted authority whose certificate was not valid at the time it stamped proves // nothing either (its detail is already recorded above): never upgrade that to Valid. - let in_validity = token.signer_certificate().is_some_and(|c| c.valid_at(token.gen_time)); + // Nor does one whose certificate is not issued for time stamping. + let may_stamp = token.signer_certificate().is_some_and(|c| c.valid_at(token.gen_time) && c.may_timestamp()); let trusted_tsa = token.signer_certificate().is_some_and(|c| build_chain(&c, &pool, Some(token.gen_time)).iter().any(|x| trust.trusts(x))); - if trusted_tsa && in_validity { + if trusted_tsa && may_stamp { info.status = Status::Valid; info.details.push(Detail::plain("The timestamp token is valid and its authority is trusted.")); } else if trusted_tsa { diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 56ec8fbdd..5dd03d086 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -165,6 +165,9 @@ const PROCESSED_EXTENSIONS: [&str; 11] = [ "1.3.6.1.5.5.7.1.3", ]; +/// id-kp-timeStamping (RFC 5280 §4.2.1.12), required of a timestamp authority (RFC 3161 §2.3). +const EKU_TIME_STAMPING: &str = "1.3.6.1.5.5.7.3.8"; + /// Extended key usages that allow signing documents: anyExtendedKeyUsage, emailProtection, /// codeSigning, documentSigning (RFC 9336), Adobe Authentic Documents and Microsoft document /// signing. @@ -384,6 +387,18 @@ impl Certificate { } } + /// Whether this certificate may stamp trusted time (RFC 3161 §2.3): its extended key usage + /// includes timeStamping, its key usage (if any) allows digital signatures or + /// non-repudiation, and it has no critical extension PdfCraft doesn't process. A certificate + /// issued for another purpose proves who signed, not when. + pub fn may_timestamp(&self) -> bool { + /// keyUsage bits 0 (digitalSignature) and 1 (nonRepudiation). + const SIGNING_BITS: u16 = 0b11; + self.extended_key_usage.as_ref().is_some_and(|e| e.iter().any(|o| o == EKU_TIME_STAMPING)) + && self.key_usage.is_none_or(|u| u & SIGNING_BITS != 0) + && self.unknown_critical.is_empty() + } + /// [`signing_problem`](Self::signing_problem) as a clause that follows the certificate's /// name ("it is not issued for signing documents (…)"), for lists of digital IDs. pub fn signing_problem_clause(&self) -> Option { diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 29f61305f..f7dc33f68 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -384,6 +384,38 @@ struct TestTsa { time: Time, } +/// A timestamp authority's digital ID: a self-signed certificate valid from October 2026 for +/// ten years, whose critical extended key usage is time stamping alone, as RFC 3161 requires. +fn tsa_id() -> DigitalId { + tsa_id_with(&["1.3.6.1.5.5.7.3.8"]) +} + +/// [`tsa_id`] with these extended key usages. +fn tsa_id_with(ekus: &[&str]) -> DigitalId { + let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap(); + let name = Name::build("Test TSA", "", "PdfCraft Tests", "", "US"); + let alg = key.preferred_digest(); + let sig_alg = key.signature_algorithm(alg); + let from = Time { year: 2026, month: 10, day: 2, hour: 0, minute: 0, second: 0 }; + let until = Time { year: 2036, ..from }; + let oids: Vec> = ekus.iter().map(|o| der::oid(o)).collect(); + let oids: Vec<&[u8]> = oids.iter().map(Vec::as_slice).collect(); + let eku = der::seq(&[&der::oid("2.5.29.37"), &der::boolean(true), &der::octets(&der::seq(&oids))]); + let tbs = der::seq(&[ + &der::explicit(0, &der::int(2)), + &der::uint(&[0x31]), + &sig_alg, + &name.raw, + &der::seq(&[&from.encode(), &until.encode()]), + &name.raw, + &key.public_key().spki(), + &der::explicit(3, &der::seq(&[&eku])), + ]); + let signature = key.sign(alg, &tbs).unwrap(); + let certificate = Certificate::parse(&der::seq(&[&tbs, &sig_alg, &der::bit_string(&signature)])).unwrap(); + DigitalId { key, certificate, chain: Vec::new(), friendly_name: None } +} + impl TimestampAuthority for TestTsa { fn timestamp(&self, request: &[u8]) -> Result, SignError> { let q = pdfcraft_sign::timestamp::parse_request(request)?; @@ -404,7 +436,7 @@ impl TimestampAuthority for TestTsa { fn signing_with_a_timestamp_embeds_a_verified_rfc3161_token() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap(); @@ -443,7 +475,7 @@ fn a_malformed_timestamp_response_fails_signing_without_a_file() { #[test] fn a_document_timestamp_covers_the_file_and_validates() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); @@ -480,7 +512,7 @@ fn a_document_timestamp_covers_the_file_and_validates() { #[test] fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let mut source = open(&fixture()); @@ -559,7 +591,7 @@ fn sign_then_ltv_then_timestamp_makes_a_b_lta_file() { use pdfcraft_sign::dss::{self, Evidence}; let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); @@ -1532,7 +1564,7 @@ fn an_untrusted_timestamp_does_not_set_the_validation_time() { // The TSA claims 2027 (inside the signer's validity) but is not trusted; the signer's own // clock says 2040, after the certificate expired. let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, }; let late = SignOptions { date: "D:20400101120000Z".into(), ..opts() }; @@ -1754,7 +1786,7 @@ fn fixture_timestamped_in_a_field(tsa: &TestTsa) -> Vec { #[test] fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let stamped = fixture_timestamped_in_a_field(&tsa); @@ -1786,7 +1818,7 @@ fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() { #[test] fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), // Before the TSA certificate's validity (it starts in October 2026). time: Time { year: 2020, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, }; @@ -1802,7 +1834,7 @@ fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() { #[test] fn a_standalone_timestamp_typed_sig_by_an_older_writer_is_still_a_timestamp() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); @@ -2036,7 +2068,7 @@ fn signing_waits_for_a_pending_security_change_to_be_saved() { #[test] fn encrypted_documents_take_a_document_timestamp() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let base = protected(pdfcraft_cos::Algorithm::Aes128, FILL_AND_SIGN_ONLY); @@ -2495,3 +2527,23 @@ fn switching_the_trailer_to_another_signed_catalog_is_not_permitted() { assert!(matches!(s.modification, Modification::Disallowed(_)), "{:?}", s.modification); assert_eq!(s.status, Status::Invalid, "{:?}", s.details); } + +/// Only a certificate issued for time stamping makes trusted time. A trusted certificate issued +/// for something else (here e-mail) stamps nothing trusted: neither a document timestamp nor +/// a signature's embedded one. +#[test] +fn a_timestamp_from_a_certificate_not_issued_for_time_stamping_is_not_trusted_time() { + let tsa = TestTsa { id: tsa_id_with(&["1.3.6.1.5.5.7.3.4"]), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; + let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); + let trust = TrustStore { certs: vec![tsa.id.certificate.clone()] }; + let s = signatures(&open(&stamped), &stamped, &trust).into_iter().find(|s| s.doc_timestamp).unwrap(); + assert_eq!(s.status, Status::Unknown, "{:?}", s.details); + assert!(s.details.iter().any(|d| d.message.contains("not issued for time stamping")), "{:?}", s.details); + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone(), tsa.id.certificate.clone()] }; + let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap(); + assert!(s.timestamp); + assert_eq!(s.timestamp_time, None, "{:?}", s.details); + assert!(s.details.iter().any(|d| d.message.contains("not issued for time stamping")), "{:?}", s.details); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1d121c59b..73b0ffbf1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2379,6 +2379,7 @@ action wizard Start Iniciar The timestamp token could not be read ({}). No se ha podido leer el sello de tiempo ({}). The document has been altered or corrupted since the timestamp was applied. El documento se ha alterado o se ha dañado desde que se aplicó el sello de tiempo. The timestamp authority's certificate was not valid at the time of timestamping. El certificado de la autoridad de sellado de tiempo no era válido en el momento del sellado. + The timestamp authority's certificate is not issued for time stamping. El certificado de la autoridad de sellado de tiempo no está emitido para sellar el tiempo. This document has not been modified since this timestamp was applied. Este documento no se ha modificado desde que se aplicó este sello de tiempo. The document has been modified since this timestamp was applied, but the changes are permitted ({}). El documento se ha modificado desde que se aplicó este sello de tiempo, pero son cambios que permite ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). El documento se ha alterado desde que se aplicó este sello de tiempo de maneras que no permite ({}). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8176e498b..6836f3e0a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2523,6 +2523,7 @@ animation effects Off Wyłączone The timestamp token could not be read ({}). Nie udało się odczytać tokenu znacznika czasu ({}). The document has been altered or corrupted since the timestamp was applied. Dokument został zmieniony lub uszkodzony po dodaniu znacznika czasu. The timestamp authority's certificate was not valid at the time of timestamping. Certyfikat urzędu znacznika czasu nie był ważny w chwili oznaczania czasem. + The timestamp authority's certificate is not issued for time stamping. Certyfikat urzędu znakowania czasem nie jest wystawiony do znakowania czasem. This document has not been modified since this timestamp was applied. Dokument nie był modyfikowany od dodania tego znacznika czasu. The document has been modified since this timestamp was applied, but the changes are permitted ({}). Dokument zmodyfikowano po dodaniu tego znacznika czasu, ale zmiany są dozwolone ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). Dokument zmieniono po dodaniu tego znacznika czasu w sposób, na który znacznik nie pozwala ({}). From 3e180944267020b471de53f87c09cafb3b9fe474 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:35:35 +0000 Subject: [PATCH 012/142] i18n(ui): carry the two new signature wordings in the catalogues that must cover them Czech and Vietnamese get translations; Catalan, Finnish, Lithuanian, Brazilian Portuguese and Romanian carry the English until translated. --- crates/ui-egui/src/i18n/ca.tsv | 2 ++ crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/fi.tsv | 2 ++ crates/ui-egui/src/i18n/lt.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ro.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 2 ++ 7 files changed, 14 insertions(+) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9cad6ad5e..dd2f497b8 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2283,6 +2283,7 @@ action wizard Start Inicia The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). El certificat del signant no s'ha emès per signar documents (el seu ús estès de clau no inclou el propòsit de signatura de documents). The signer's certificate has critical extensions PdfCraft does not recognize ({}). El certificat del signant té extensions crítiques que PdfCraft no reconeix ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. El certificat de {} no és un certificat de CA que pugui emetre certificats, de manera que no s'utilitza per avalar {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. El certificat de {} no permet tants certificats de CA per sota seu (longitud de camí), de manera que no s'utilitza per avalar {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. El certificat de {} no era vàlid en el moment de signar, de manera que no s'utilitza per avalar {}. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. La identitat del signant és desconeguda perquè no s'ha inclòs a la llista de certificats de confiança i cap dels seus certificats pare és un certificat de confiança. @@ -2302,6 +2303,7 @@ action wizard Start Inicia The timestamp token could not be read ({}). No s'ha pogut llegir el testimoni de marca de temps ({}). The document has been altered or corrupted since the timestamp was applied. El document ha estat alterat o s'ha corromput des que es va aplicar la marca de temps. The timestamp authority's certificate was not valid at the time of timestamping. El certificat de l'autoritat de marca de temps no era vàlid en el moment de marcar el temps. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. This document has not been modified since this timestamp was applied. Aquest document no s'ha modificat des que es va aplicar aquesta marca de temps. The document has been modified since this timestamp was applied, but the changes are permitted ({}). El document s'ha modificat des que es va aplicar aquesta marca de temps, però els canvis són permesos ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). El document ha estat alterat des que es va aplicar aquesta marca de temps d'una manera que no permet ({}). diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4e736bbf4..66cac29aa 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2615,6 +2615,7 @@ animation effects Off Vypnuto The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo podpisu RSA nemá parametr NULL (starší podepisovací programy jej tak zapisují); přesto byl načten. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Certifikát {} nepovoluje pod sebou tolik certifikátů CA (délka cesty), proto se nepoužije k ručení za {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Certifikát {} není certifikát CA, který smí vydávat certifikáty, proto se nepoužije k ručení za {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Certifikát {} má kritická rozšíření, která PdfCraft nezná, proto se nepoužije k ručení za {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Certifikát {} nebyl v době podpisu platný, proto se nepoužije k ručení za {}. The checker found problems which may prevent the document from being fully accessible. Kontrola našla problémy, které mohou bránit plné přístupnosti dokumentu. The document has been altered or corrupted since the signature was applied. Dokument byl od připojení podpisu změněn nebo poškozen. @@ -2657,6 +2658,7 @@ animation effects Off Vypnuto The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Identita podepisující osoby je neznámá, protože není ve vašem seznamu důvěryhodných certifikátů a žádný z jejích nadřazených certifikátů není důvěryhodný. The signer's identity is valid. Identita podepisující osoby je platná. The timestamp authority's certificate was not valid at the time of timestamping. Certifikát autority časových razítek nebyl v době vystavení razítka platný. + The timestamp authority's certificate is not issued for time stamping. Certifikát autority časových razítek není vystaven pro vydávání časových razítek. The timestamp contents are not hexadecimal. Obsah časového razítka není šestnáctkový. The timestamp has no valid byte range. Časové razítko nemá platný rozsah bajtů. The timestamp token could not be read ({}). Časové razítko nebylo možné načíst ({}). diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ddc29982..efcf3e3c6 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2496,6 +2496,7 @@ animation effects Off Ei käytössä The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Allekirjoittajan varmennetta ei ole myönnetty asiakirjojen allekirjoittamiseen (sen laajennettu avaimen käyttö ei sisällä asiakirjojen allekirjoitusta). The signer's certificate has critical extensions PdfCraft does not recognize ({}). Allekirjoittajan varmenteessa on kriittisiä laajennuksia, joita PdfCraft ei tunnista ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Kohteen {} varmenne ei ole varmenteita myöntävä CA-varmenne, joten sitä ei käytetä kohteen {} vahvistamiseen. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Kohteen {} varmenne ei salli näin montaa alempaa CA-varmennetta (polun pituus), joten sitä ei käytetä kohteen {} vahvistamiseen. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Kohteen {} varmenne ei ollut voimassa allekirjoitushetkellä, joten sitä ei käytetä kohteen {} vahvistamiseen. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Allekirjoittajan henkilöllisyys on tuntematon, koska sitä ei ole lisätty luotettujen varmenteiden luetteloon eikä mikään sen ylemmistä varmenteista ole luotettu varmenne. @@ -2515,6 +2516,7 @@ animation effects Off Ei käytössä The timestamp token could not be read ({}). Aikaleimatunnistetta ei voitu lukea ({}). The document has been altered or corrupted since the timestamp was applied. Asiakirjaa on muutettu tai se on vioittunut aikaleiman lisäämisen jälkeen. The timestamp authority's certificate was not valid at the time of timestamping. Aikaleiman myöntäjän varmenne ei ollut voimassa aikaleimaushetkellä. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. This document has not been modified since this timestamp was applied. Asiakirjaa ei ole muokattu tämän aikaleiman lisäämisen jälkeen. The document has been modified since this timestamp was applied, but the changes are permitted ({}). Asiakirjaa on muokattu tämän aikaleiman lisäämisen jälkeen, mutta muutokset ovat sallittuja ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). Asiakirjaa on muutettu tämän aikaleiman lisäämisen jälkeen tavoilla, joita se ei salli ({}). diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 0e542211f..e2476f196 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -1735,6 +1735,7 @@ The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. RSA parašo „DigestInfo“ neturi parametro NULL (taip įrašo senesnės pasirašymo programos); jis vis tiek perskaitytas. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Subjekto {} sertifikatas neleidžia tiek žemesnio lygio sertifikavimo įstaigų sertifikatų (grandinės ilgio riba), todėl juo nepatvirtinamas {} sertifikatas. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Subjekto {} sertifikatas nėra sertifikavimo įstaigos sertifikatas, leidžiantis išduoti sertifikatus, todėl juo nepatvirtinamas {} sertifikatas. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Subjekto {} sertifikatas pasirašymo metu negaliojo, todėl juo nepatvirtinamas {} sertifikatas. The checker found problems which may prevent the document from being fully accessible. Tikrintuvas aptiko problemų, dėl kurių dokumentas gali būti nevisiškai pritaikytas prieinamumui. The document has been altered or corrupted since the signature was applied. Po pasirašymo dokumentas buvo pakeistas arba pažeistas. @@ -1783,6 +1784,7 @@ The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Pasirašiusiojo tapatybė nežinoma, nes jo sertifikato nėra patikimų sertifikatų sąraše ir nė vienas aukštesnio lygio sertifikatas nėra patikimas. The signer's identity is valid. Pasirašiusiojo tapatybė patvirtinta. The timestamp authority's certificate was not valid at the time of timestamping. Laiko žymą išdavusios įstaigos sertifikatas žymos sukūrimo metu negaliojo. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. The timestamp contents are not hexadecimal. Laiko žymos turinys nėra šešioliktainis. The timestamp has no valid byte range. Laiko žyma neturi tinkamo baitų intervalo. The timestamp token could not be read ({}). Nepavyko perskaityti laiko žymos žetono ({}). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8fe72f99b..dcbebae95 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2634,6 +2634,7 @@ animation effects Off Desativados The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. O DigestInfo da assinatura RSA não tem o parâmetro NULL (assinantes mais antigos o incluem); mesmo assim, ele foi lido. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. O certificado de {} não permite essa quantidade de certificados de AC abaixo dele (comprimento do caminho), então não é usado para atestar a validade de {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. O certificado de {} não é um certificado de AC autorizado a emitir certificados, então não é usado para atestar a validade de {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. O certificado de {} não era válido no momento da assinatura, então não é usado para atestar a validade de {}. The checker found problems which may prevent the document from being fully accessible. A verificação encontrou problemas que podem impedir que o documento seja totalmente acessível. The document has been altered or corrupted since the signature was applied. O documento foi alterado ou corrompido desde que a assinatura foi aplicada. @@ -2680,6 +2681,7 @@ animation effects Off Desativados The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. A identidade do signatário é desconhecida porque não foi incluída na sua lista de certificados confiáveis e nenhum dos certificados na cadeia acima dela é confiável. The signer's identity is valid. A identidade do signatário é válida. The timestamp authority's certificate was not valid at the time of timestamping. O certificado da autoridade de carimbo de data e hora não era válido no momento da emissão do carimbo. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. The timestamp contents are not hexadecimal. O conteúdo do carimbo de data e hora não está em hexadecimal. The timestamp has no valid byte range. O carimbo de data e hora não tem um intervalo de bytes válido. The timestamp token could not be read ({}). Não foi possível ler o token de carimbo de data e hora ({}). diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 5a8025dc8..0003e3851 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -1684,6 +1684,7 @@ The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo din semnătura RSA nu are parametrul NULL (semnatarii mai vechi îl scriu așa); a fost citită oricum. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Certificatul lui {} nu permite atâtea certificate CA sub el (lungimea căii), așa că nu este folosit pentru a garanta pentru {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Certificatul lui {} nu este un certificat CA care poate emite certificate, așa că nu este folosit pentru a garanta pentru {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Certificatul lui {} nu era valabil în momentul semnării, așa că nu este folosit pentru a garanta pentru {}. The checker found problems which may prevent the document from being fully accessible. Verificatorul a găsit probleme care pot împiedica accesibilitatea completă a documentului. The document has been altered or corrupted since the signature was applied. Documentul a fost modificat sau deteriorat după aplicarea semnăturii. @@ -1729,6 +1730,7 @@ The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Identitatea semnatarului este necunoscută, deoarece nu a fost inclusă în lista certificatelor de încredere și niciunul dintre certificatele părinte nu este de încredere. The signer's identity is valid. Identitatea semnatarului este validă. The timestamp authority's certificate was not valid at the time of timestamping. Certificatul autorității de marcare temporală nu era valabil în momentul marcării. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. The timestamp contents are not hexadecimal. Conținutul marcajului temporal nu este hexazecimal. The timestamp has no valid byte range. Marcajul temporal nu are un interval de octeți valid. The timestamp token could not be read ({}). Tokenul de marcaj temporal nu a putut fi citit ({}). diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bb20dcc5b..87abe5129 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1724,6 +1724,7 @@ theme Appearance Giao diện The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo của chữ ký RSA không có tham số NULL (các trình ký cũ ghi như vậy); nó vẫn được đọc. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Chứng chỉ của {} không cho phép có nhiều chứng chỉ CA ở dưới nó như vậy (độ dài đường dẫn), nên nó không được dùng để bảo chứng cho {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Chứng chỉ của {} không phải là chứng chỉ CA được phép cấp chứng chỉ, nên nó không được dùng để bảo chứng cho {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Chứng chỉ của {} có các phần mở rộng quan trọng mà PdfCraft không nhận ra, nên nó không được dùng để bảo chứng cho {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Chứng chỉ của {} không hợp lệ tại thời điểm ký, nên nó không được dùng để bảo chứng cho {}. The checker found problems which may prevent the document from being fully accessible. Trình kiểm tra tìm thấy các vấn đề có thể khiến tài liệu không thể tiếp cận đầy đủ. The document has been altered or corrupted since the signature was applied. Tài liệu đã bị thay đổi hoặc hỏng kể từ khi chữ ký được áp dụng. @@ -1772,6 +1773,7 @@ theme Appearance Giao diện The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Danh tính của người ký không rõ vì nó chưa được đưa vào danh sách chứng chỉ tin cậy của bạn và không chứng chỉ cấp trên nào của nó là chứng chỉ tin cậy. The signer's identity is valid. Danh tính của người ký hợp lệ. The timestamp authority's certificate was not valid at the time of timestamping. Chứng chỉ của cơ quan cấp dấu thời gian không hợp lệ tại thời điểm đóng dấu thời gian. + The timestamp authority's certificate is not issued for time stamping. Chứng chỉ của cơ quan cấp dấu thời gian không được cấp cho mục đích đóng dấu thời gian. The timestamp contents are not hexadecimal. Nội dung dấu thời gian không phải là thập lục phân. The timestamp has no valid byte range. Dấu thời gian không có dải byte hợp lệ. The timestamp token could not be read ({}). Không thể đọc mã thông báo dấu thời gian ({}). From 7c4731234f0e5a819461c98400676595e2a07b07 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:36:37 +0000 Subject: [PATCH 013/142] style(sign): rustfmt the new tests --- crates/sign/tests/it/chain.rs | 6 +++-- crates/sign/tests/it/crypto.rs | 3 ++- crates/sign/tests/it/pdf.rs | 40 +++++++--------------------------- 3 files changed, 14 insertions(+), 35 deletions(-) diff --git a/crates/sign/tests/it/chain.rs b/crates/sign/tests/it/chain.rs index 2b6b5f85a..1cd5f028a 100644 --- a/crates/sign/tests/it/chain.rs +++ b/crates/sign/tests/it/chain.rs @@ -348,13 +348,15 @@ fn an_unreadable_extended_key_usage_allows_no_purpose() { fn an_issuer_with_an_unprocessed_critical_extension_does_not_vouch() { let name_constraints = ext("2.5.29.30", &der::seq(&[])); let r = root(); - let constrained = issue("Name Constrained CA", Some(&r), FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints.clone()]); + let constrained = + issue("Name Constrained CA", Some(&r), FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints.clone()]); let l = end_entity("Outside Its Names", &constrained); let pool = [constrained.cert.clone(), r.cert.clone()]; let (chain, why) = build_chain_noted(&l.cert, &pool, None); assert_eq!(names(&chain), ["Outside Its Names"]); assert!(why.is_some_and(|w| w.message.contains("critical extensions")), "the reason is reported"); - let constrained_root = issue("Name Constrained Root", None, FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints]); + let constrained_root = + issue("Name Constrained Root", None, FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints]); let i = ca("Under Constrained Root", &constrained_root, None); let l = end_entity("Leaf", &i); let pool = [i.cert.clone(), constrained_root.cert.clone()]; diff --git a/crates/sign/tests/it/crypto.rs b/crates/sign/tests/it/crypto.rs index 193fd06c6..f184d7161 100644 --- a/crates/sign/tests/it/crypto.rs +++ b/crates/sign/tests/it/crypto.rs @@ -482,7 +482,8 @@ fn a_responder_that_copies_the_issuers_name_does_not_answer_for_it() { let issuer = &id.certificate; let (this, next) = (mid_june(), Time { year: 2026, month: 7, day: 15, hour: 12, minute: 0, second: 0 }); let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap(); - let impostor = Certificate::self_signed(&issuer.subject, &key, Time { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, 5, &[9]).unwrap(); + let impostor = + Certificate::self_signed(&issuer.subject, &key, Time { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, 5, &[9]).unwrap(); assert_eq!(impostor.subject.raw, issuer.subject.raw); let cert_id = der::seq(&[ &DigestAlg::Sha1.algorithm(), diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index f7dc33f68..61517b53d 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -435,10 +435,7 @@ impl TimestampAuthority for TestTsa { #[test] fn signing_with_a_timestamp_embeds_a_verified_rfc3161_token() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap(); assert!(signed.starts_with(&fixture()), "still an incremental update"); let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); @@ -474,10 +471,7 @@ fn a_malformed_timestamp_response_fails_signing_without_a_file() { #[test] fn a_document_timestamp_covers_the_file_and_validates() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); assert!(stamped.starts_with(&fixture()), "an incremental update"); let trust_tsa = TrustStore { certs: vec![tsa.id.certificate.clone()] }; @@ -511,10 +505,7 @@ fn a_document_timestamp_covers_the_file_and_validates() { #[test] fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let mut source = open(&fixture()); source .update_dict(source.root().unwrap(), |d| { @@ -590,10 +581,7 @@ fn embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid() { fn sign_then_ltv_then_timestamp_makes_a_b_lta_file() { use pdfcraft_sign::dss::{self, Evidence}; let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); let ltv = dss::embed(&open(&signed), &Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }).unwrap(); let lta = pdfcraft_sign::timestamp_document(&open(<v), &tsa, "D:20261006120000Z").unwrap(); @@ -1563,10 +1551,7 @@ fn an_untrusted_timestamp_does_not_set_the_validation_time() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); // The TSA claims 2027 (inside the signer's validity) but is not trusted; the signer's own // clock says 2040, after the certificate expired. - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 } }; let late = SignOptions { date: "D:20400101120000Z".into(), ..opts() }; let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &late, &tsa).unwrap(); let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); @@ -1785,10 +1770,7 @@ fn fixture_timestamped_in_a_field(tsa: &TestTsa) -> Vec { #[test] fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let stamped = fixture_timestamped_in_a_field(&tsa); let trust = TrustStore { certs: vec![tsa.id.certificate.clone()] }; let all = signatures(&open(&stamped), &stamped, &trust); @@ -1833,10 +1815,7 @@ fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() { #[test] fn a_standalone_timestamp_typed_sig_by_an_older_writer_is_still_a_timestamp() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); let s = signatures(&open(&stamped), &stamped, &TrustStore::default()).into_iter().find(|s| s.doc_timestamp).unwrap(); assert_eq!(s.status, Status::Unknown, "{:?}", s.details); @@ -2067,10 +2046,7 @@ fn signing_waits_for_a_pending_security_change_to_be_saved() { #[test] fn encrypted_documents_take_a_document_timestamp() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let base = protected(pdfcraft_cos::Algorithm::Aes128, FILL_AND_SIGN_ONLY); let stamped = pdfcraft_sign::timestamp_document(&open_pw(&base, None), &tsa, "D:20261006120000Z").unwrap(); assert!(stamped.starts_with(&base)); From 7cdde6c91c3343a3af23088dfdd69c1a69bd823b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:43:26 +0000 Subject: [PATCH 014/142] chore(assets): record the changed translation catalogues in ATTRIBUTION.toml --- ATTRIBUTION.md | 6 +++--- ATTRIBUTION.toml | 24 ++++++++++++------------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4efb31105..593ae6e68 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -13,13 +13,13 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | @@ -285,7 +285,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | | `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format | | `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | | `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 7f70df3f8..3f42a5401 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7358300715a54e72a477c5888dad53f426ddf76da141a6202bec74594eb5ff0a" +sha256 = "b08f8282923d2faaf1f658d2aff8f71fe5f2d3a33e792eeb50fb0e2b396ef24f" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,18 +63,18 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "265083ea7dfa129fcae0b9e401aef8f7c84982011ba91fb2023c52705b81e639" +sha256 = "df17dada3df736e75bd9b03e5ecceec164cce63767e33a1efdb98c56a121c890" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" title = "Spanish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ffcf42859cfe38ae13c6fe4219cbe6225a20b4572c202db9e82ee38bfb433799" +sha256 = "8e8c0cfda0886c584e5286370ec00814206c3af3699c76665e636fa6fdb6310b" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -135,12 +135,12 @@ sha256 = "76f138293fc12ea3584568a81f6f90a1cedfff6256e78b4f60cb21b1b66bbba4" path = "crates/ui-egui/src/i18n/pl.tsv" title = "Polish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7bca90240f873fcec687bea84e9ea5835f0903827501d0e73ec26b98b82e8a52" +sha256 = "0bd75a93f8d18deb832223bd0642cc4f4abde04a322b684a143a9d4b4687a344" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -250,7 +250,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dea5076ce9c19f054812b678241931ad5e7f85dee08acf1c1e12c7ecaaa5d0f5" +sha256 = "bbef7f4e16f83e9e0116278e7a0a175bad79757f96a18dc639516572533f6d5b" [[asset]] path = "crates/ui-egui/src/i18n/hi.tsv" @@ -292,7 +292,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "18d4beda8495d7f0b91f0dc0a52c2248d9ce6fb3f4e3de0d51f42235eaab7aa8" +sha256 = "00a2153c20a191fbe04f1ce61f140fd9628cfe2f8f75dad5bce8a7406e1bd091" [[asset]] @@ -304,7 +304,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7b13e11191a3f2c0e38f1940996ae62da16e3bdb057215b2737f3a9c6a840000" +sha256 = "609efa92054aa76de4bb6062c0fcc0a8b450519f4b253b4c0f395b3cbb34d2ba" [[asset]] path = "assets/icons/accessibility.svg" @@ -3421,12 +3421,12 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" path = "crates/ui-egui/src/i18n/vi.tsv" title = "Vietnamese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1c73f9d51b599923d704bf00c8e56b23696de16141b7a2c20aae6f60f88927a5" +sha256 = "57f0dd007ad16518fbc87d8aeac840ef6d86412bb460aff9fb17b176408fb7a8" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3537,4 +3537,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cde106ae68eea0a2d6229863ea21bbea235e4c3c5e3e728d52689d54041f0593" +sha256 = "5879ee3540ffb630fc83d0f2cb293cc6cbf2e081ea4577161a770794e8cbd22c" From d5047aa4acdf35158c3943839dc5c98f16a8f955 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:07:32 +0000 Subject: [PATCH 015/142] test(cos): build the private temp directory without an unused mut on Windows The only mutable use of the DirBuilder is the Unix mode call, so the binding is mutable only where that call exists. --- crates/cos/tests/it/encryption.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index 8d03d2279..2c6aad244 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -237,9 +237,14 @@ fn private_temp_dir(prefix: &str) -> std::path::PathBuf { let mut name = std::collections::hash_map::RandomState::new().build_hasher(); name.write_u32(std::process::id()); let dir = std::env::temp_dir().join(format!("{prefix}-{:016x}", name.finish())); - let mut builder = std::fs::DirBuilder::new(); #[cfg(unix)] - std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + let builder = { + let mut builder = std::fs::DirBuilder::new(); + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder + }; + #[cfg(not(unix))] + let builder = std::fs::DirBuilder::new(); builder.create(&dir).unwrap(); dir } From 733e38bfa71840d1e96e806d81a2544871ec7d85 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:06:47 +0000 Subject: [PATCH 016/142] fix(cos): a decode budget shared by every stream one walk reads MAX_DECODED bounds one decode, so a page whose /Contents repeats a reference to one compressed stream, or a form drawn many times, gets a fresh 1 GiB allowance per occurrence. DecodeBudget is one cumulative decoded-byte allowance plus a form-entry count and a seen-set of stream references, shared by a whole page walk. A decode that runs past the remaining allowance spends all of it. --- crates/cos/src/budget.rs | 135 +++++++++++++++++++++++++++++++++++++++ crates/cos/src/lib.rs | 2 + crates/cos/src/object.rs | 14 +++- 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 crates/cos/src/budget.rs diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs new file mode 100644 index 000000000..b89d06001 --- /dev/null +++ b/crates/cos/src/budget.rs @@ -0,0 +1,135 @@ +//! A shared allowance for operations that decode many streams. + +use std::collections::HashSet; + +use pdfcraft_filters::FilterError; + +use crate::{CosError, Document, ObjRef, Object, Stream}; + +/// Decoded bytes one page's content walk may produce in all: its `/Contents` streams and every +/// form XObject it enters, each occurrence counted again. +pub const MAX_PAGE_DECODED: usize = 256 << 20; +/// Form XObjects one page's content walk may enter, each entry counted again. +pub const MAX_PAGE_FORMS: usize = 50_000; + +/// What one operation may still decode and enter across all the streams it reads. +/// +/// [`MAX_DECODED`](crate::MAX_DECODED) bounds one decode. It does not bound a page whose +/// `/Contents` names the same compressed stream thousands of times, or a form drawn thousands of +/// times from other forms: each occurrence would get a fresh allowance. A `DecodeBudget` is +/// shared by every decode of one operation, so the total decoded bytes and form entries stay +/// under fixed limits however the file repeats its references. A decode that runs past the +/// remaining allowance spends all of it, so a bomb referenced many times is inflated once. +#[derive(Debug)] +pub struct DecodeBudget { + bytes: usize, + forms: usize, + seen: HashSet, +} + +impl DecodeBudget { + /// An allowance of `bytes` decoded bytes and `forms` form entries. + pub fn new(bytes: usize, forms: usize) -> Self { + Self { bytes, forms, seen: HashSet::new() } + } + + /// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms. + pub fn page() -> Self { + Self::new(MAX_PAGE_DECODED, MAX_PAGE_FORMS) + } + + /// Decoded bytes still allowed. + pub fn remaining(&self) -> usize { + self.bytes + } + + /// Whether a decode was refused for lack of allowance or a form entry for lack of visits. + pub fn exhausted(&self) -> bool { + self.bytes == 0 || self.forms == 0 + } + + /// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without + /// decoding, once the allowance is spent. + pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { + if self.bytes == 0 && !stream.raw.is_empty() { + return Err(CosError::Filter(FilterError::LimitExceeded(0).to_string())); + } + match stream.decoded_within_typed(self.bytes) { + Ok(data) => { + self.bytes = self.bytes.saturating_sub(data.len()); + Ok(data) + } + Err(e) => { + if matches!(e, FilterError::LimitExceeded(_)) { + self.bytes = 0; + } + Err(CosError::Filter(e.to_string())) + } + } + } + + /// Counts one form entry. `false` once [`DecodeBudget::new`]'s `forms` entries are spent: + /// the caller leaves that form out. + pub fn enter_form(&mut self) -> bool { + match self.forms.checked_sub(1) { + Some(left) => { + self.forms = left; + true + } + None => false, + } + } + + /// `true` the first time `r` is passed to this budget, for callers that need each + /// referenced stream only once. + pub fn first_visit(&mut self, r: ObjRef) -> bool { + self.seen.insert(r) + } + + /// Each stream `contents` names, decoded against the allowance, in order. `contents` is a + /// page's `/Contents`: one stream, or an array whose non-stream entries are left out. + /// A stream that fails to decode, or arrives after the allowance is spent, is an `Err` in its + /// place, so the caller chooses between skipping it and giving up. + pub fn decode_contents(&mut self, doc: &Document, contents: &Object) -> Vec, CosError>> { + match &*doc.resolve(contents) { + Object::Stream(s) => vec![self.decode(s)], + Object::Array(list) => list + .iter() + .filter_map(|entry| match &*doc.resolve(entry) { + Object::Stream(s) => Some(self.decode(s)), + _ => None, + }) + .collect(), + _ => Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Dict; + + #[test] + fn repeated_references_share_one_allowance() { + let mut doc = Document::new_empty(); + let bomb = doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]))); + let contents = Object::Array(vec![Object::Ref(bomb); 64]); + let mut budget = DecodeBudget::new(10 << 20, 1); + let decoded = budget.decode_contents(&doc, &contents); + assert_eq!(decoded.len(), 64); + assert_eq!(decoded.iter().filter(|d| d.is_ok()).count(), 10); + let total: usize = decoded.iter().flatten().map(Vec::len).sum(); + assert_eq!(total, 10 << 20); + assert!(budget.exhausted()); + // A decode past the allowance spends it, so a bomb is inflated at most once more. + let mut budget = DecodeBudget::new((1 << 20) - 1, 1); + assert!(budget.decode_contents(&doc, &contents).iter().all(Result::is_err)); + assert_eq!(budget.remaining(), 0); + + assert!(budget.enter_form()); + assert!(!budget.enter_form()); + assert!(budget.first_visit(bomb)); + assert!(!budget.first_visit(bomb)); + } +} diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 152daed2d..f3be66b9a 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -13,6 +13,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod budget; mod bytes; mod document; mod object; @@ -21,6 +22,7 @@ mod parser; mod security; mod writer; +pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS}; pub use bytes::Bytes; pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 438b8d5f3..fe33388be 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -214,11 +214,21 @@ impl Stream { /// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than /// [`MAX_DECODED`]: decoding past `max` bytes is an error. pub fn decoded_within(&self, max: usize) -> Result, CosError> { + self.decoded_within_typed(max).map_err(|e| CosError::Filter(e.to_string())) + } + + /// [`Stream::decoded_within`] with the filter error kept typed, so a caller can tell a + /// spent limit from corrupt data. + pub(crate) fn decoded_within_typed(&self, max: usize) -> Result, pdfcraft_filters::FilterError> { + let max = max.min(MAX_DECODED); let chain = self.filters(); if chain.is_empty() { - return self.raw_within(max); + if self.raw.len() > max { + return Err(pdfcraft_filters::FilterError::LimitExceeded(max)); + } + return Ok(self.raw.to_vec()); } - pdfcraft_filters::decode_tolerant(&chain, &self.raw, max.min(MAX_DECODED)).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string())) + pdfcraft_filters::decode_tolerant(&chain, &self.raw, max).map(|(v, _)| v) } /// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error. From 13d9c9bf8c83d7f87484ab4665f9998abb691392 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:48 +0000 Subject: [PATCH 017/142] fix(a11y): page content and forms share one decode budget per page The checker decoded every /Contents entry and every form a page drew with a fresh 1 GiB allowance each, so a page repeating one compressed stream reference produced gigabytes. The two copies of the page-content helper are one now, and it and the form walk charge a per-page DecodeBudget. --- crates/a11y/src/alt.rs | 30 ++++++++++++++---------------- crates/a11y/src/content.rs | 33 +++++++++------------------------ crates/a11y/src/tests.rs | 10 ++++++++++ 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index e4755ab38..57f9390b1 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::Matrix; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString, Stream}; /// A Figure element (after the role map). #[derive(Clone, Debug, PartialEq)] @@ -157,22 +157,19 @@ fn bdc_mcid(doc: &Document, props: &Dict, op: &pdfcraft_content::Op) -> Option Vec { + page_content_within(doc, page, &mut DecodeBudget::page()) +} + +/// The page's content streams, decoded against `budget` and joined with a space. Streams that +/// don't decode, or arrive after the budget is spent, are left out. +pub(crate) fn page_content_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Vec { let Some(c) = page.get(b"Contents") else { return Vec::new() }; - match &*doc.resolve(c) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => a - .iter() - .flat_map(|x| match &*doc.resolve(x) { - Object::Stream(s) => { - let mut v = s.decoded().unwrap_or_default(); - v.push(b' '); - v - } - _ => Vec::new(), - }) - .collect(), - _ => Vec::new(), + let mut out = Vec::new(); + for data in budget.decode_contents(doc, c).into_iter().flatten() { + out.extend_from_slice(&data); + out.push(b' '); } + out } fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { @@ -322,9 +319,10 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) }; let props = page_properties(doc, &page.dict); let mut streams: Vec)>> = Vec::with_capacity(entries.len()); + let mut budget = DecodeBudget::page(); for entry in &entries { match &*doc.resolve(entry) { - Object::Stream(s) => streams.push(Some((s.clone(), s.decoded().unwrap_or_default()))), + Object::Stream(s) => streams.push(Some((s.clone(), budget.decode(s).unwrap_or_default()))), _ => streams.push(None), } } diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index 8f7221b2c..66f83867e 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object}; use pdfcraft_model::Page; use crate::{Finding, Rule}; @@ -30,38 +30,22 @@ impl Scan { for &p in list { let Some(page) = pages.get(p) else { continue }; let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let data = page_content(doc, &page.dict); - let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new() }; + let mut budget = DecodeBudget::page(); + let data = crate::alt::page_content_within(doc, &page.dict, &mut budget); + let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new(), budget }; w.content(&data, &res, false, 0); } s } } -fn page_content(doc: &Document, page: &Dict) -> Vec { - let Some(c) = page.get(b"Contents") else { return Vec::new() }; - match &*doc.resolve(c) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => a - .iter() - .flat_map(|x| match &*doc.resolve(x) { - Object::Stream(s) => { - let mut v = s.decoded().unwrap_or_default(); - v.push(b' '); - v - } - _ => Vec::new(), - }) - .collect(), - _ => Vec::new(), - } -} - struct Walk<'a> { doc: &'a Document, scan: &'a mut Scan, page: usize, forms: HashSet, + /// Decoded bytes and form entries left for this page. + budget: DecodeBudget, } impl Walk<'_> { @@ -116,14 +100,15 @@ impl Walk<'_> { self.scan.has_images = true; untagged += usize::from(!inside); } - Some(b"Form") if depth < MAX_FORM_DEPTH && self.forms.insert(r) => { + Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) && self.budget.enter_form() => { + self.forms.insert(r); let inner = x .dict .get(b"Resources") .map(|o| self.doc.resolve(o)) .and_then(|o| o.as_dict().cloned()) .unwrap_or_else(|| res.clone()); - let data = x.decoded().unwrap_or_default(); + let data = self.budget.decode(x).unwrap_or_default(); self.content(&data, &inner, inside, depth + 1); self.forms.remove(&r); } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 15ca643e8..84a836cbf 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1191,3 +1191,13 @@ fn horizontally_scaled_wrapped_heading_keeps_a_space_between_lines() { let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); assert_eq!(titles, ["I Overview"]); } + +#[test] +fn repeated_content_references_share_one_decode_budget() { + use pdfcraft_cos::{Dict, MAX_PAGE_DECODED, Object, Stream}; + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect(); + let data = crate::alt::page_content(&doc, &page); + assert!(data.len() <= MAX_PAGE_DECODED + 300, "{} bytes", data.len()); +} From 0b6adb70dab892dbead62e2c33bf5d3ef1557077 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:09:44 +0000 Subject: [PATCH 018/142] fix(optimize): page content and forms share one decode budget per page Image measurement and fragment merging decoded every /Contents entry and every form occurrence with a fresh 1 GiB allowance, so repeated references to one compressed stream multiplied memory. Both charge a per-page DecodeBudget. A page whose content does not decode within it is left unmerged, and its images are not resampled, since a use may be missed; fragment merging no longer rewrites a page from content it could not fully decode. --- crates/optimize/src/fragments.rs | 6 ++- crates/optimize/src/images.rs | 67 ++++++++++++++++++++++---------- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index b5967eee0..a73ac9830 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -18,7 +18,7 @@ //! drops them. use pdfcraft_content::{Matrix, Op, parse, serialize_ops}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; use crate::images::{components, content_bytes, decodable, page_resources, pixels, resample}; use crate::{OptimizeError, Report, Stage}; @@ -213,7 +213,9 @@ fn add_xobject(doc: &mut Document, page: ObjRef, name: &[u8], image: ObjRef) -> /// Merge the fragment runs of one page; returns how many fragments were merged. fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result { let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) }; - let parsed = parse(&content_bytes(doc, &contents)); + // A page whose content isn't all known is left alone: rewriting it would drop the rest. + let Some(data) = content_bytes(doc, &contents, &mut DecodeBudget::page()) else { return Ok(0) }; + let parsed = parse(&data); if parsed.skipped > 0 { return Ok(0); } diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 6182b0892..531c56ece 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::{Matrix, Pieces}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; @@ -23,21 +23,11 @@ pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { None } -pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec { - match &*doc.resolve(contents) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => { - let pieces: Vec<_> = a - .iter() - .filter_map(|c| match &*doc.resolve(c) { - Object::Stream(s) => Some(s.decoded().unwrap_or_default()), - _ => None, - }) - .collect(); - Pieces::join(&pieces).data().to_vec() - } - _ => Vec::new(), - } +/// The page content `contents` names, decoded against `budget` and joined. `None` when a stream +/// doesn't decode or the budget runs out: the content is not all known. +pub(crate) fn content_bytes(doc: &Document, contents: &Object, budget: &mut DecodeBudget) -> Option> { + let pieces = budget.decode_contents(doc, contents).into_iter().collect::, _>>().ok()?; + Some(Pieces::join(&pieces).data().to_vec()) } /// Forms nested deeper than this aren't followed. @@ -57,6 +47,8 @@ struct Walk<'a> { /// Forms walked at least once. seen: HashSet, rewalks: usize, + /// Decoded bytes left for the page being walked. + budget: DecodeBudget, /// False once a use may have been missed (depth or work limit). complete: bool, out: HashMap, @@ -135,23 +127,42 @@ impl Walk<'_> { } self.rewalks += 1; } + let Ok(data) = self.budget.decode(s) else { + self.complete = false; + return; + }; self.path.push(r); - self.content(&s.decoded().unwrap_or_default(), own.as_ref().unwrap_or(resources), inner); + self.content(&data, own.as_ref().unwrap_or(resources), inner); self.path.pop(); } } /// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses. /// -/// If forms nest too deeply or are drawn too many times to measure every use, every image is -/// given `INFINITY` (never resampled), since its largest use may be among those missed. +/// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within +/// its budget, every image is given `INFINITY` (never resampled), since its largest use may be +/// among those missed. pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap { - let mut walk = Walk { doc, path: Vec::new(), walked: HashSet::new(), seen: HashSet::new(), rewalks: 0, complete: true, out: HashMap::new() }; + let mut walk = Walk { + doc, + path: Vec::new(), + walked: HashSet::new(), + seen: HashSet::new(), + rewalks: 0, + budget: DecodeBudget::page(), + complete: true, + out: HashMap::new(), + }; for p in pages { let Some(d) = doc.get(*p).as_dict().cloned() else { continue }; let Some(contents) = d.get(b"Contents") else { continue }; let res = page_resources(doc, *p).unwrap_or_default(); - walk.content(&content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + walk.budget = DecodeBudget::page(); + let Some(data) = content_bytes(doc, contents, &mut walk.budget) else { + walk.complete = false; + continue; + }; + walk.content(&data, &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); } if !walk.complete { walk.out.values_mut().for_each(|v| *v = f64::INFINITY); @@ -433,3 +444,17 @@ mod dimension_tests { assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4])); } } + +#[cfg(test)] +mod tests { + use pdfcraft_cos::{DecodeBudget, Dict, Document, MAX_PAGE_DECODED, Object, Stream}; + + #[test] + fn repeated_content_references_share_one_decode_budget() { + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let contents = Object::Array(vec![Object::Ref(stream); 300]); + let data = super::content_bytes(&doc, &contents, &mut DecodeBudget::page()); + assert!(data.is_none_or(|d| d.len() <= MAX_PAGE_DECODED + 300)); + } +} From 8c8f56a2e29d77370721864198732685f8e624b8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:11:06 +0000 Subject: [PATCH 019/142] fix(print): page content and label forms share one decode budget per page Printing and the label text-direction check decoded every /Contents entry with a fresh 1 GiB allowance, and the direction check re-entered forms at every Do with only a depth limit, so a few hundred bytes of nested repeated draws meant hundreds of thousands of form walks. Both charge a per-page DecodeBudget: printing refuses a page whose content runs past it, and the direction check stops reading there. --- crates/print/src/lib.rs | 51 +++++++++++++++++++++++---------------- crates/print/src/tests.rs | 39 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index e935cb938..05b0770ba 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -18,7 +18,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use pdfcraft_content::Matrix; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; pub mod range; pub mod spool; @@ -331,11 +331,14 @@ pub fn text_reads_up(doc: &Document, index: usize) -> bool { let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut score = 0i64; - text_direction(doc, &decoded_contents(doc, &page.dict), &res, to_display, 0, &mut score); + let mut budget = DecodeBudget::page(); + // Content past the budget is left out: the answer is a guess from the text read so far. + let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default(); + text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget); score > 0 } -fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64) { +fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); for op in pdfcraft_content::parse(data).ops { match op.op.as_slice() { @@ -358,8 +361,11 @@ fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: } let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); - if let Ok(body) = form.decoded() { - text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score); + if !budget.enter_form() { + return; + } + if let Ok(body) = budget.decode(form) { + text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); } } _ => {} @@ -676,24 +682,22 @@ fn n(v: f64) -> String { if s == "-0" { "0".into() } else { s.into() } } -fn decoded_contents(doc: &Document, page: &Dict) -> Vec { - let list: Vec = match page.get(b"Contents") { - None => Vec::new(), - Some(c) => match &*doc.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }, - }; +/// The page's content streams decoded against `budget` and joined; streams that don't decode +/// are left out. `None` when the budget ran out, so part of the content is missing. +fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { + let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; let mut out = Vec::new(); - for o in list { - if let Object::Stream(s) = &*doc.resolve(&o) - && let Ok(d) = s.decoded() - { - out.extend_from_slice(&d); - out.push(b'\n'); + for data in budget.decode_contents(doc, contents) { + match data { + Ok(data) => { + out.extend_from_slice(&data); + out.push(b'\n'); + } + Err(_) if budget.remaining() == 0 => return None, + Err(_) => {} } } - out + Some(out) } /// Does annotation `d` print under `content`? @@ -760,7 +764,12 @@ fn page_form( let page = pdfcraft_model::pages(doc).swap_remove(index); let crop = page.crop(doc); let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut body = if content == Content::FormFieldsOnly { Vec::new() } else { decoded_contents(doc, &page.dict) }; + let mut body = if content == Content::FormFieldsOnly { + Vec::new() + } else { + decoded_contents(doc, &page.dict, &mut DecodeBudget::page()) + .ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))? + }; // Wrap the page content so a state it leaves behind can't affect the annotations. if !body.is_empty() { let mut wrapped = b"q\n".to_vec(); diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 99634f1aa..fcf1a6268 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -877,3 +877,42 @@ fn driver_options_take_the_ppds_own_names_in_the_ui_language() { } mod appearances; + +#[test] +fn repeated_content_references_share_one_decode_budget() { + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect(); + let data = decoded_contents(&doc, &page, &mut DecodeBudget::page()); + assert!(data.is_none_or(|d| d.len() <= pdfcraft_cos::MAX_PAGE_DECODED + 300)); +} + +/// 20 draws of a form that draws the next form 20 times, four levels down: 168,420 form +/// entries from a few hundred bytes. +fn form_fanout(doc: &mut Document) -> Dict { + let draws = "/X Do ".repeat(20); + let mut form = |content: &str, inner: Option| { + let mut d: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + if let Some(inner) = inner { + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + let res: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + d.set(b"Resources".to_vec(), Object::Dict(res)); + } + doc.add(Stream::from_raw(d, content.as_bytes().to_vec())) + }; + let mut inner = form("BT 0 1 -1 0 0 0 Tm (a) Tj ET", None); + for _ in 0..3 { + inner = form(&draws, Some(inner)); + } + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect() +} + +#[test] +fn text_direction_stops_at_the_form_budget() { + let mut doc = Document::new_empty(); + let res = form_fanout(&mut doc); + let mut score = 0; + text_direction(&doc, "/X Do ".repeat(20).as_bytes(), &res, Matrix::IDENTITY, 0, &mut score, &mut DecodeBudget::page()); + assert!(score > 0 && score <= pdfcraft_cos::MAX_PAGE_FORMS as i64, "score {score}"); +} From 9c9bbe2ad68d36bab7434593fa4842fea7bf3af1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:12:05 +0000 Subject: [PATCH 020/142] fix(organize): page content is joined within one decode budget Pruning and page_as_form joined every /Contents entry with a fresh 1 GiB allowance each, so a page naming one compressed stream many times became gigabytes. Both share one helper that decodes against a per-page DecodeBudget and fails when it runs out. --- crates/organize/src/import.rs | 17 ++++----------- crates/organize/src/prune.rs | 33 ++++++++++++----------------- crates/organize/src/tests.rs | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 33 deletions(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 80f90fb2a..024dfa5c3 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -1354,19 +1354,10 @@ pub fn page_as_form(dst: &mut Document, src: &Document, page: usize) -> Result<( let (w, h) = (bbox[2] - bbox[0], bbox[3] - bbox[1]); let rotate = d.get(b"Rotate").and_then(|r| src.resolve(r).as_int()).unwrap_or(0).rem_euclid(360); // Content: the page's streams, decoded and joined. - let mut content = Vec::new(); - if let Some(c) = own.get(b"Contents") { - let list = match &*src.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }; - for o in list { - if let Object::Stream(s) = &*src.resolve(&o) { - content.extend(s.decoded().map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?); - content.push(b'\n'); - } - } - } + let content = match own.get(b"Contents") { + Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?, + None => Vec::new(), + }; let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); let resources = match d.get(b"Resources") { Some(r) => copier.copy_value(dst, r, None)?, diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index b5f5e65ff..a42c34299 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, ObjRef, Object}; use crate::OrganizeError; @@ -223,23 +223,16 @@ impl Pruner<'_> { } } -/// The page's content, concatenated (`/Contents` may be one stream or an array). `None` when a -/// stream can't be decoded: the page may draw names that can't be seen. -fn content_bytes(doc: &Document, contents: &Object) -> Option> { - match &*doc.resolve(contents) { - Object::Stream(s) => s.decoded().ok(), - Object::Array(a) => { - let mut out = Vec::new(); - for c in a { - if let Object::Stream(s) = &*doc.resolve(c) { - out.extend(s.decoded().ok()?); - out.push(b'\n'); - } - } - Some(out) - } - _ => Some(Vec::new()), +/// The page's content, concatenated (`/Contents` may be one stream or an array), decoded +/// against one page's [`DecodeBudget`]. An error when a stream can't be decoded or the budget +/// runs out: the page may draw names that can't be seen. +pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result, CosError> { + let mut out = Vec::new(); + for data in DecodeBudget::page().decode_contents(doc, contents) { + out.extend(data?); + out.push(b'\n'); } + Ok(out) } /// The resources of a page (as `walk` reports them) with their owner. @@ -321,9 +314,9 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result {} - Some(data) => pruner.content(&data, &res_dict, owner, 0), - None => { + Ok(data) if data.is_empty() => {} + Ok(data) => pruner.content(&data, &res_dict, owner, 0), + Err(_) => { pruner.protect_type3_resources(&res_dict, owner); pruner.distrust(&res_dict, owner); } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 7a80e38e4..bc833257f 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -3132,3 +3132,43 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_copying_attributes() { assert!(walk.next().is_none()); assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3"]); } + +/// One page whose /Contents names the same 1 MiB stream 300 times (a few kilobytes on disk). +fn repeated_contents() -> Document { + use pdfcraft_cos::{Dict, Stream}; + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let pages = doc.add(Object::Null); + let page: Dict = [ + (b"Type".to_vec(), Object::Name(b"Page".to_vec())), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())), + (b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300])), + ] + .into_iter() + .collect(); + let page = doc.add(page); + let tree: Dict = [ + (b"Type".to_vec(), Object::Name(b"Pages".to_vec())), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(); + doc.set(pages, Object::Dict(tree)); + let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect(); + let catalog = doc.add(catalog); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_content_references_share_one_decode_budget() { + let src = repeated_contents(); + let mut dst = Document::new_empty(); + if let Ok((form, _)) = page_as_form(&mut dst, &src, 0) { + let Object::Stream(s) = &*dst.get(form) else { panic!("not a stream") }; + let len = s.decoded().unwrap().len(); + assert!(len <= pdfcraft_cos::MAX_PAGE_DECODED + 300, "{len} bytes"); + } +} From 92ab0e932d21e0a118900a09081424aa0b3e3195 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:12:11 +0000 Subject: [PATCH 021/142] fix(cos): a spent decode budget reports its whole allowance The error named the bytes left at the failing decode (often 0), which says nothing about why the operation stopped. --- crates/cos/src/budget.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index b89d06001..35319f13b 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -22,6 +22,8 @@ pub const MAX_PAGE_FORMS: usize = 50_000; /// remaining allowance spends all of it, so a bomb referenced many times is inflated once. #[derive(Debug)] pub struct DecodeBudget { + /// The whole allowance, for errors. + limit: usize, bytes: usize, forms: usize, seen: HashSet, @@ -30,7 +32,7 @@ pub struct DecodeBudget { impl DecodeBudget { /// An allowance of `bytes` decoded bytes and `forms` form entries. pub fn new(bytes: usize, forms: usize) -> Self { - Self { bytes, forms, seen: HashSet::new() } + Self { limit: bytes, bytes, forms, seen: HashSet::new() } } /// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms. @@ -52,19 +54,18 @@ impl DecodeBudget { /// decoding, once the allowance is spent. pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { if self.bytes == 0 && !stream.raw.is_empty() { - return Err(CosError::Filter(FilterError::LimitExceeded(0).to_string())); + return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } match stream.decoded_within_typed(self.bytes) { Ok(data) => { self.bytes = self.bytes.saturating_sub(data.len()); Ok(data) } - Err(e) => { - if matches!(e, FilterError::LimitExceeded(_)) { - self.bytes = 0; - } - Err(CosError::Filter(e.to_string())) + Err(FilterError::LimitExceeded(_)) => { + self.bytes = 0; + Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) } + Err(e) => Err(CosError::Filter(e.to_string())), } } From bb8ea65ffdc69bf589a40d1ac5b95b5e13899dad Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:14:53 +0000 Subject: [PATCH 022/142] fix(forms): script streams share one decode budget per reading Each field action and document-script entry decoded its /JS stream with a fresh 1 GiB allowance and kept its own copy, so many fields or name-tree pairs naming one compressed stream retained gigabytes. A reading of the fields, one field's actions, and the document scripts each share a 64 MiB DecodeBudget for their script streams. --- crates/forms/src/actions.rs | 7 +++-- crates/forms/src/lib.rs | 52 +++++++++++++++++++++++------------ crates/forms/src/scripting.rs | 3 +- crates/forms/src/tests.rs | 40 +++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 8128b6a71..cdc89e563 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -105,11 +105,11 @@ fn texts(doc: &Document, o: &Object) -> Vec { } } -fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef]) -> Option { +fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option { let a = doc.resolve(o); let d = a.as_dict()?; Some(match d.name(b"S")? { - b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o).unwrap_or_default()), + b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o, scripts).unwrap_or_default()), b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()), b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()), b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()), @@ -145,13 +145,14 @@ pub fn field_actions(doc: &Document, name: &str) -> Result wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), Some(k) => aa.get(k).cloned(), }; // An action chain (/Next) counts as its first action here. - if let Some(a) = o.and_then(|o| read(doc, &o, &pages)) { + if let Some(a) = o.and_then(|o| read(doc, &o, &pages, &mut scripts)) { out.push((t, a)); } } diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 85284b8cc..7bd87a2ef 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -14,7 +14,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString}; mod actions; pub mod af; @@ -264,7 +264,7 @@ fn name_obj(text: &str) -> Object { const MAX_LAYER_STATE: usize = 1024; /// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). -fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option { +fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option { // A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action): // clicking it picks a picture, as `buttonImportIcon()` does. if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { @@ -345,7 +345,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option af::button_script(&script(doc, &action)?), + b"JavaScript" => af::button_script(&script(doc, &action, scripts)?), _ => return None, }) } @@ -371,20 +371,30 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option { (!parts.is_empty()).then(|| parts.join(".")) } -/// The JavaScript of an action dictionary (`/JS` string or stream). -fn script(doc: &Document, action: &Object) -> Option { +/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode +/// in all, however many actions name the same stream. +const MAX_SCRIPT_BYTES: usize = 64 << 20; + +/// A budget for the script streams of one reading of the form. +fn script_budget() -> DecodeBudget { + DecodeBudget::new(MAX_SCRIPT_BYTES, 0) +} + +/// The JavaScript of an action dictionary (`/JS` string or stream). A stream is decoded against +/// `budget`, and is `None` once it is spent. +fn script(doc: &Document, action: &Object, budget: &mut DecodeBudget) -> Option { let a = doc.resolve(action); let js = a.as_dict()?.get(b"JS")?.clone(); match &*doc.resolve(&js) { Object::String(s) => Some(s.to_text()), - Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + Object::Stream(s) => budget.decode(s).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), _ => None, } } /// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only /// widget's when the field has none). -fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions { +fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> af::Actions { let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| { (widgets.len() == 1) .then(|| doc.get(widgets[0])) @@ -393,7 +403,7 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions { }); let mut out = af::Actions::default(); let Some(aa) = aa else { return out }; - let js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a)); + let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts)); if let Some(f) = js(b"F") { match af::parse_format(&f) { Some(fm) => out.format = fm, @@ -580,7 +590,7 @@ fn enumerate(doc: &Document) -> (Vec, usize) { None => Inherited::default(), }; let mut out = Vec::new(); - let mut seen = std::collections::HashSet::new(); + let mut seen = Seen { fields: std::collections::HashSet::new(), scripts: script_budget() }; if let Some(af) = &af { for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { if let Some(r) = f.as_ref() { @@ -604,7 +614,7 @@ fn adopt_page_fields( pages: &[ObjRef], base: &Inherited, page_of: &std::collections::HashMap, - seen: &mut std::collections::HashSet, + seen: &mut Seen, out: &mut Vec, ) { for p in pages { @@ -613,7 +623,7 @@ fn adopt_page_fields( let Some(annots) = annots.as_array() else { continue }; for e in annots { let Some(r) = e.as_ref() else { continue }; - if seen.contains(&r) { + if seen.fields.contains(&r) { continue; } let obj = doc.get(r); @@ -624,7 +634,7 @@ fn adopt_page_fields( if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") { continue; } - if let Some(root) = unlisted_root(doc, r, seen) { + if let Some(root) = unlisted_root(doc, r, &seen.fields) { walk(doc, root, base, page_of, seen, out, 0); } } @@ -756,16 +766,23 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re /// Read the field at `r` and the fields under it into `out`, with what they inherit from /// `parent`: kids with a name are fields of their own, kids without are this field's widgets. +/// What one reading of the form's fields has used: the field dictionaries read, and the script +/// budget their actions share. +struct Seen { + fields: std::collections::HashSet, + scripts: DecodeBudget, +} + fn walk( doc: &Document, r: ObjRef, parent: &Inherited, page_of: &std::collections::HashMap, - seen: &mut std::collections::HashSet, + seen: &mut Seen, out: &mut Vec, depth: usize, ) { - if depth > 64 || !seen.insert(r) { + if depth > 64 || !seen.fields.insert(r) { return; } let obj = doc.get(r); @@ -808,9 +825,10 @@ fn walk( return; } let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; - let actions = actions_of(doc, d, &widget_refs); - let button = - (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0).then(|| button_action(doc, d, &widget_refs)).flatten(); + let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts); + let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0) + .then(|| button_action(doc, d, &widget_refs, &mut seen.scripts)) + .flatten(); let ff = inh.ff.unwrap_or(0); let kind = match inh.ft.as_deref() { Some(b"Tx") => FieldKind::Text, diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index f5b827445..7dd7bfde6 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -176,6 +176,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() }; let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() }; let mut out: Vec<(String, String)> = Vec::new(); + let mut scripts = crate::script_budget(); // Kids stay unresolved on the stack, and each node is read once: a node listed many times, // or among its own kids, must not multiply the work. let mut stack = vec![tree]; @@ -203,7 +204,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { Object::String(s) => s.to_text(), _ => continue, }; - if let Some(js) = crate::script(doc, v) { + if let Some(js) = crate::script(doc, v, &mut scripts) { out.push((key, js)); } } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b2e4994ec..b2b8573eb 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2981,3 +2981,43 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() { assert_eq!(walk.next(), None); assert_eq!(page_refs(&doc), [page]); } + +/// 300 fields and 300 document scripts that all name one 1 MiB JavaScript stream. +fn repeated_script_references() -> Document { + use pdfcraft_cos::Stream; + let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) }; + let mut doc = Document::new_empty(); + let js = doc.add(Stream::flate(pdfcraft_cos::Dict::new(), &vec![b'x'; 1 << 20])); + let action = doc.add(dict(vec![(b"S", Object::Name(b"JavaScript".to_vec())), (b"JS", Object::Ref(js))])); + let fields: Vec = (0..300) + .map(|i| { + Object::Ref(doc.add(dict(vec![ + (b"FT", Object::Name(b"Tx".to_vec())), + (b"T", Object::String(pdfcraft_cos::PdfString::literal(format!("f{i}")))), + (b"Rect", Object::Array([0, 0, 10, 10].map(Object::from).to_vec())), + (b"AA", dict(vec![(b"C", Object::Ref(action))])), + ]))) + }) + .collect(); + let names: Vec = + (0..300).flat_map(|i| [Object::String(pdfcraft_cos::PdfString::literal(format!("s{i:03}"))), Object::Ref(action)]).collect(); + let catalog = doc.add(dict(vec![ + (b"Type", Object::Name(b"Catalog".to_vec())), + (b"AcroForm", dict(vec![(b"Fields", Object::Array(fields))])), + (b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Names", Object::Array(names))]))])), + ])); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_script_references_share_one_decode_budget() { + let doc = repeated_script_references(); + let limit = MAX_SCRIPT_BYTES; + let named: usize = document_scripts_named(&doc).iter().map(|(_, js)| js.len()).sum(); + assert!(named > 0 && named <= limit, "{named} bytes of document scripts"); + let all = fields(&doc); + assert_eq!(all.len(), 300); + let calculate: usize = all.iter().filter_map(|f| f.actions.scripts.calculate.as_ref()).map(String::len).sum(); + assert!(calculate > 0 && calculate <= limit, "{calculate} bytes of field scripts"); +} From d2e7ce72f045bc85e1bcc9e784d43708190a8305 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:17:10 +0000 Subject: [PATCH 023/142] fix(redact): form entries and page content share one budget per page The interpreter entered a form again at every Do with only a nesting limit, so a few shared forms drawing each other produced exponential work in apply, sanitize and verify. Scope carries a per-page DecodeBudget for form entries and form bytes; past it, apply refuses the page and verification counts the form as possible residue. Page content streams are decoded against a page budget too. --- crates/redact/src/interp.rs | 25 +++++++++++++++++++++++-- crates/redact/src/lib.rs | 6 ++++-- crates/redact/src/tests.rs | 17 +++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index c9a404b93..5b3742385 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string}; -use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRef, Object, Stream}; use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; @@ -102,6 +102,9 @@ pub(crate) struct Scope<'a> { fonts: HashMap, Rc>, used_names: Vec>, depth: usize, + /// Form entries and decoded form bytes left for this page: shared forms drawn from other + /// shared forms would otherwise multiply the work with every level. + budget: DecodeBudget, } impl<'a> Scope<'a> { @@ -120,6 +123,7 @@ impl<'a> Scope<'a> { fonts: HashMap::new(), used_names: Vec::new(), depth: 0, + budget: DecodeBudget::page(), } } @@ -1201,8 +1205,25 @@ fn xobject( out.doomed.push(r); return Some(None); } - let data = match s.decoded() { + let data = if scope.budget.enter_form() { + scope.budget.decode(s) + } else { + Err(CosError::Filter(format!("the page draws more than {MAX_PAGE_FORMS} form XObjects"))) + }; + let data = match data { Ok(d) => d, + // Past the page's budget nothing more is read: apply refuses the page, and + // verification counts the form as content that may remain. + Err(e) if scope.budget.exhausted() => { + if scope.mode == Mode::Apply { + out.error = Some(e); + } else if scope.everywhere() { + scope.layer_blocks += 1; + } else { + out.residue += 1; + } + return None; + } Err(_) if scope.mode == Mode::Apply => { scope.report.forms_removed += 1; out.replaced.push(name); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6df5102af..8407e0c8f 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -219,7 +219,8 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec, Vec) { (out, unreadable) } -/// A page's content streams: (the references or inline objects as listed, their decoded data). +/// A page's content streams: (the references or inline objects as listed, their decoded data), +/// decoded within one page's [`pdfcraft_cos::DecodeBudget`]. pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec, Vec>), RedactError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), @@ -229,9 +230,10 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result< }, }; let mut data = Vec::new(); + let mut budget = pdfcraft_cos::DecodeBudget::page(); for o in &list { match &*doc.resolve(o) { - Object::Stream(s) => data.push(s.decoded().map_err(|_| RedactError::Unreadable(index + 1))?), + Object::Stream(s) => data.push(budget.decode(s).map_err(|_| RedactError::Unreadable(index + 1))?), Object::Null => data.push(Vec::new()), _ => return Err(RedactError::Unreadable(index + 1)), } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 286667937..11cae9bb9 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2472,3 +2472,20 @@ fn a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_under_a_mark() { assert_eq!(under(&mut doc, 0, &[[45.0, 95.0, 50.0, 110.0]]), 0, "nothing is drawn past LASTARR's 35pt width"); assert_eq!(under(&mut doc, 0, &[[10.0, 95.0, 45.0, 110.0]]), 7, "LASTARR's 7 glyphs, the one the page draws"); } + +#[test] +fn form_entries_share_one_budget_per_page() { + // 20 draws of a form that draws the next 20 times, four levels down: 160,000 glyphs from a + // few hundred bytes. + let draws = "/X Do ".repeat(20); + let mut objs = vec![stream("/Type /XObject /Subtype /Form /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 10 100 Td (A) Tj ET")]; + for inner in 7..10 { + objs.push(stream(&format!("/Type /XObject /Subtype /Form /Resources << /XObject << /X {inner} 0 R >> >>"), draws.as_bytes())); + } + let mut doc = one_page(draws.as_bytes(), "/XObject << /X 10 0 R >>", objs); + let residue = under(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]]); + assert!(residue > 0 && residue <= pdfcraft_cos::MAX_PAGE_FORMS, "{residue} glyphs counted"); + // Applying refuses the page rather than leave forms past the budget unread. + mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], ""); + assert!(apply(&mut doc, None).is_err()); +} From ea4939cd08823bdd390797f5c245fe55bfb0c6e5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:17:54 +0000 Subject: [PATCH 024/142] fix(cos): a strict decode charged to a DecodeBudget The redaction audit treats any damaged stream as a failure, so it needs the strict decoder under the same shared allowance. --- crates/cos/src/budget.rs | 16 +++++++++++++++- crates/cos/src/object.rs | 21 ++++++++++++++------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 35319f13b..cf1c3fc2a 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -53,10 +53,20 @@ impl DecodeBudget { /// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without /// decoding, once the allowance is spent. pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { + self.charge(stream, Stream::decoded_within_typed) + } + + /// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to + /// the allowance. + pub fn decode_strict(&mut self, stream: &Stream) -> Result, CosError> { + self.charge(stream, Stream::decoded_strict_within_typed) + } + + fn charge(&mut self, stream: &Stream, decode: fn(&Stream, usize) -> Result, FilterError>) -> Result, CosError> { if self.bytes == 0 && !stream.raw.is_empty() { return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } - match stream.decoded_within_typed(self.bytes) { + match decode(stream, self.bytes) { Ok(data) => { self.bytes = self.bytes.saturating_sub(data.len()); Ok(data) @@ -127,6 +137,10 @@ mod tests { let mut budget = DecodeBudget::new((1 << 20) - 1, 1); assert!(budget.decode_contents(&doc, &contents).iter().all(Result::is_err)); assert_eq!(budget.remaining(), 0); + let mut budget = DecodeBudget::new((1 << 20) - 1, 1); + let Object::Stream(stream) = &*doc.get(bomb) else { panic!("not a stream") }; + assert!(budget.decode_strict(stream).is_err()); + assert_eq!(budget.remaining(), 0); assert!(budget.enter_form()); assert!(!budget.enter_form()); diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index fe33388be..9a07d4822 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -223,21 +223,24 @@ impl Stream { let max = max.min(MAX_DECODED); let chain = self.filters(); if chain.is_empty() { - if self.raw.len() > max { - return Err(pdfcraft_filters::FilterError::LimitExceeded(max)); - } - return Ok(self.raw.to_vec()); + return self.raw_within_typed(max); } pdfcraft_filters::decode_tolerant(&chain, &self.raw, max).map(|(v, _)| v) } /// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error. pub fn decoded_strict(&self) -> Result, CosError> { + self.decoded_strict_within_typed(MAX_DECODED).map_err(|e| CosError::Filter(e.to_string())) + } + + /// [`Stream::decoded_strict`] bounded by `max`, with the filter error kept typed. + pub(crate) fn decoded_strict_within_typed(&self, max: usize) -> Result, pdfcraft_filters::FilterError> { + let max = max.min(MAX_DECODED); let chain = self.filters(); if chain.is_empty() { - return self.raw_within(MAX_DECODED); + return self.raw_within_typed(max); } - pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string())) + pdfcraft_filters::decode(&chain, &self.raw, max) } /// Data after the first filter alone, bounded by `max`: the rest of the chain stays encoded. @@ -252,11 +255,15 @@ impl Stream { } fn raw_within(&self, max: usize) -> Result, CosError> { + self.raw_within_typed(max).map_err(|e| CosError::Filter(e.to_string())) + } + + fn raw_within_typed(&self, max: usize) -> Result, pdfcraft_filters::FilterError> { let max = max.min(MAX_DECODED); // An empty decoder chain still produces an owned output buffer: check its limit // before cloning, including streams whose /Crypt filter was applied on load. if self.raw.len() > max { - return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string())); + return Err(pdfcraft_filters::FilterError::LimitExceeded(max)); } Ok(self.raw.to_vec()) } From b5cddc884d7bef1a724a339e0e91c5fd5264b904 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:18:42 +0000 Subject: [PATCH 025/142] fix(audit): page content and forms share one decode budget per page The cover walk decoded every /Contents entry and every form draw with a fresh 1 GiB allowance; the operator bound does not count whitespace, so a large blank form drawn many times meant hours of decoding. The walk charges a per-page DecodeBudget with the strict decoder, and spending it fails the audit. --- crates/audit/src/cover.rs | 14 +++++++++----- crates/audit/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 3df666c19..516bb193d 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -16,7 +16,7 @@ //! Positions compare lexicographically in paint order. use pdfcraft_content::{Matrix, Op, Pieces, parse}; -use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; use crate::AuditError; @@ -109,6 +109,8 @@ struct Walker<'a> { pieces: &'a Pieces, out: Vec, ops_seen: usize, + /// Decoded bytes left for the page, its forms included: spending it fails the audit. + budget: DecodeBudget, } impl<'a> Walker<'a> { @@ -272,7 +274,7 @@ impl<'a> Walker<'a> { .unwrap_or(Matrix::IDENTITY); let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); // A form stream that cannot be decoded fails the audit: it might hide a cover. - let data = stream.decoded_strict()?; + let data = self.budget.decode_strict(stream)?; let ops = parse(&data).ops; // The XObject matrix maps form space into the user space active at `Do`. let mut sub_gs = gs.clone(); @@ -304,7 +306,8 @@ fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { /// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint /// positions. A page's content streams are walked once, joined, with one graphics state, so /// state set in an earlier stream is still in force in the later ones. Any content stream that -/// cannot be decoded is an audit failure, never an empty result. +/// cannot be decoded, or content and forms that decode past one page's [`DecodeBudget`] in all, +/// is an audit failure, never an empty result. pub fn filled_rects(doc: &Document, page: &Dict) -> Result, AuditError> { let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let contents: Vec = match page.get(b"Contents") { @@ -314,15 +317,16 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result, Audi _ => vec![c.clone()], }, }; + let mut budget = DecodeBudget::page(); let mut streams = Vec::with_capacity(contents.len()); for c in &contents { if let Object::Stream(s) = &*doc.resolve(c) { - streams.push(s.decoded_strict()?); + streams.push(budget.decode_strict(s)?); } } let pieces = Pieces::join(&streams); let parsed = pieces.parse(); - let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0 }; + let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget }; let mut gs = Gs::default(); let mut stack = Vec::new(); w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?; diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 4ca3b0bf5..8155c6dec 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -592,3 +592,22 @@ fn reading_blocks_respect_stream_comments_and_split_operands() { assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1)); } } + +#[test] +fn repeated_stream_references_share_one_decode_budget() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + // One 1 MiB stream of spaces (a kilobyte on disk), named 300 times: by the page's /Contents, + // and by 300 draws of a form holding it. + let mut doc = Document::new_empty(); + let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let contents: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(spaces); 300]))].into_iter().collect(); + assert!(crate::cover::filled_rects(&doc, &contents).is_err(), "repeated content streams"); + + let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::flate(form_dict, &vec![b' '; 1 << 20])); + let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(300).into_bytes())); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); + assert!(crate::cover::filled_rects(&doc, &page).is_err(), "repeated form draws"); +} From a37a908f7b56539dfb843c756b301af9577c27e8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:22:17 +0000 Subject: [PATCH 026/142] fix(edit): page content, forms and image records share per-page limits The editor's page readers decoded every /Contents entry and every form entry with a fresh 1 GiB allowance, and the reading-order image inventory added a record per image draw in every form entered, so a few shared forms drawing each other produced millions of records. Content streams and form entries are decoded against one per-page DecodeBudget (content past it is an error, so no edit rewrites a page from part of its content; forms past it are reported truncated), and an inventory past 100,000 image draws is refused. --- crates/edit/src/background.rs | 2 +- crates/edit/src/images.rs | 43 +++++++++---------- crates/edit/src/tests.rs | 75 +++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 72 +++++++++++++++++++------------ crates/edit/src/text_regions.rs | 2 +- 5 files changed, 143 insertions(+), 51 deletions(-) diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs index d1d50e450..7d745c0c1 100644 --- a/crates/edit/src/background.rs +++ b/crates/edit/src/background.rs @@ -50,7 +50,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE let crop = p.crop(doc); let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); - let streams = crate::text::content_streams(doc, &p.dict); + let streams = crate::text::content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 75bb12f47..0f75ff3d3 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -73,23 +73,16 @@ struct ClipPath { even_odd: bool, } -fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { - let list: Vec = match page.get(b"Contents") { - None => Vec::new(), - Some(c) => match &*doc.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }, - }; - list.into_iter() - .map(|o| { - let data = match &*doc.resolve(&o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }; - (o, data) - }) - .collect() +fn streams(doc: &Document, page: &Dict) -> Result)>, EditError> { + crate::text::content_streams(doc, page) +} + +/// Image draws one page's inventory lists at most: a form drawn many times from other forms +/// would otherwise multiply a few hundred bytes into millions of records. +pub(crate) const MAX_PAGE_IMAGES: usize = 100_000; + +fn too_many_images() -> EditError { + EditError::Invalid(format!("the page draws more than {MAX_PAGE_IMAGES} images")) } /// The page's streams joined into the one stream they are: an operator may span two of them. @@ -237,7 +230,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr let mut out = Vec::new(); // The streams are one stream in pieces, so the graphics state and operators carry over. let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let pieces = joined(&streams(doc, &p.dict)); + let pieces = joined(&streams(doc, &p.dict)?); for (i, op) in pieces.parse().ops.iter().enumerate() { state.apply(op)?; let ctm = state.placement.matrix; @@ -285,6 +278,9 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr { continue; } + if out.len() >= MAX_PAGE_IMAGES { + return Err(too_many_images()); + } out.push(PageImage { rect, visible_rect: intersection(rect, state.placement.clip), @@ -358,6 +354,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi if s.dict.name(b"Subtype") != Some(b"Image") { continue; } + if out.len() >= MAX_PAGE_IMAGES { + return Err(too_many_images()); + } out.push(PageImage { rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]), visible_rect: intersection(ctm.bbox([0.0, 0.0, 1.0, 1.0]), st.placement.clip), @@ -382,7 +381,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() }; - let pieces = joined(&streams(doc, &p.dict)); + let pieces = joined(&streams(doc, &p.dict)?); walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) } @@ -483,7 +482,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let images = page_images(doc, page)?; let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = streams(doc, &p.dict)?; let pieces = joined(&all); let ops = pieces.parse().ops; if !ops.get(img.op).is_some_and(|op| op.is("Do")) { @@ -531,7 +530,7 @@ pub(crate) fn selectable_images( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = streams(doc, &p.dict)?; Ok(page_images(doc, page)? .into_iter() .enumerate() @@ -755,7 +754,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize } let images = page_images(doc, page)?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = streams(doc, &p.dict)?; let mut plans = std::collections::BTreeMap::new(); for index in indexes { let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index cf3860237..61e5e958e 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6355,3 +6355,78 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() { ] ); } + +/// A one-page document from the page's /Contents and /Resources, built in code. +fn page_with(build: impl FnOnce(&mut Document) -> (Object, Dict)) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.add(Object::Null); + let (contents, resources) = build(&mut doc); + let page: Dict = [ + (b"Type".to_vec(), Object::Name(b"Page".to_vec())), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())), + (b"Contents".to_vec(), contents), + (b"Resources".to_vec(), Object::Dict(resources)), + ] + .into_iter() + .collect(); + let page = doc.add(page); + let tree: Dict = [ + (b"Type".to_vec(), Object::Name(b"Pages".to_vec())), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(); + doc.set(pages, Object::Dict(tree)); + let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect(); + let catalog = doc.add(catalog); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_content_references_share_one_decode_budget() { + // One 1 MiB stream (a kilobyte on disk) named 300 times by the page's /Contents. + let doc = page_with(|doc| { + let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + (Object::Array(vec![Object::Ref(spaces); 300]), Dict::new()) + }); + match text_lines(&doc, 0) { + Ok(lines) => panic!("read {} lines from more content than a page may decode", lines.len()), + Err(e) => assert!(matches!(e, EditError::Invalid(_)), "{e}"), + } +} + +#[test] +fn reading_images_stops_at_the_page_record_limit() { + // 20 draws of a form drawing a form 20 times that draws an image 20 times: 8,000 images + // per level-two form, 160,000 in all, from a few hundred bytes. + let doc = page_with(|doc| { + let image: Dict = [ + (b"Subtype".to_vec(), Object::Name(b"Image".to_vec())), + (b"Width".to_vec(), Object::Int(1)), + (b"Height".to_vec(), Object::Int(1)), + (b"ColorSpace".to_vec(), Object::Name(b"DeviceGray".to_vec())), + (b"BitsPerComponent".to_vec(), Object::Int(8)), + ] + .into_iter() + .collect(); + let mut inner = doc.add(Stream::from_raw(image, vec![0])); + let draws = "/X Do ".repeat(20); + let resources = |inner: pdfcraft_cos::ObjRef| -> Dict { + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect() + }; + for _ in 0..3 { + let mut d = resources(inner); + d = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec())), (b"Resources".to_vec(), Object::Dict(d))].into_iter().collect(); + inner = doc.add(Stream::from_raw(d, draws.clone().into_bytes())); + } + let page = doc.add(Stream::from_raw(Dict::new(), draws.into_bytes())); + (Object::Ref(page), resources(inner)) + }); + if let Ok(images) = crate::reading_images(&doc, 0) { + assert!(images.len() <= crate::images::MAX_PAGE_IMAGES, "{} images", images.len()); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 93a5eec99..5784b95f3 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; -use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, PdfString, Stream}; use pdfcraft_fonts::pdf::Metrics; use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from}; @@ -615,7 +615,10 @@ struct Ts { unreadable: Option>, } -pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { +/// The page's content streams as listed, each with its decoded data (empty when it doesn't +/// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading +/// or rewriting a page from part of its content would lose the rest. +pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result)>, EditError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -623,15 +626,20 @@ pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec vec![c.clone()], }, }; - list.into_iter() - .map(|o| { - let data = match &*doc.resolve(&o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }; - (o, data) - }) - .collect() + let mut budget = DecodeBudget::page(); + let mut out = Vec::with_capacity(list.len()); + for o in list { + let data = match &*doc.resolve(&o) { + Object::Stream(s) => match budget.decode(s) { + Ok(data) => data, + Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))), + Err(_) => Vec::new(), + }, + _ => Vec::new(), + }; + out.push((o, data)); + } + Ok(out) } /// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators @@ -1284,7 +1292,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro let mut carry = Carry::new(); // The streams are one stream in pieces (§7.8.2), split between any two tokens: parse them // joined, so an operator whose operands end one piece still has them. - let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); + let pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::>()); let ops = pieces.parse().ops; let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default(); let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, Some(&properties))?; @@ -1490,10 +1498,11 @@ pub const MAX_FORM_DEPTH: usize = 50; pub const MAX_FORM_VISITS: usize = 50_000; /// One page's walk through the form XObjects it draws: how many it entered (the page's -/// [`MAX_FORM_VISITS`]), and whether a limit left a form unread. -#[derive(Default)] +/// [`MAX_FORM_VISITS`]), the bytes their content may decode to in all, and whether a limit left +/// a form unread. pub(crate) struct FormBudget { visits: usize, + decode: DecodeBudget, /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past /// [`MAX_FORM_VISITS`]: what it draws is missing from the walk. pub(crate) truncated: bool, @@ -1501,6 +1510,12 @@ pub(crate) struct FormBudget { /// A form XObject a page draws: its object, content, resources and its matrix composed with the /// CTM at the `Do`. +impl Default for FormBudget { + fn default() -> Self { + Self { visits: 0, decode: DecodeBudget::page(), truncated: false } + } +} + pub(crate) struct FormCall { pub(crate) obj: Option, pub(crate) data: Vec, @@ -1519,7 +1534,7 @@ pub(crate) fn form_call( path: &[pdfcraft_cos::ObjRef], budget: &mut FormBudget, ) -> Option { - let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS; + let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS || budget.decode.remaining() == 0; // Once a limit has cut the page short, refusing the rest costs nothing more. if limited && budget.truncated { return None; @@ -1546,13 +1561,16 @@ pub(crate) fn form_call( }); // A form without its own resources uses the ones it was drawn with (PDF 1.1 files). let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let data = match budget.decode.decode(s) { + Ok(data) => data, + Err(_) if budget.decode.remaining() == 0 => { + budget.truncated = true; + return None; + } + Err(_) => Vec::new(), + }; budget.visits = budget.visits.saturating_add(1); - Some(FormCall { - obj, - data: s.decoded().unwrap_or_default(), - resources: own.unwrap_or_else(|| resources.clone()), - ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)), - }) + Some(FormCall { obj, data, resources: own.unwrap_or_else(|| resources.clone()), ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) }) } /// The lines a page shows as it reads, including text drawn by form XObjects (which @@ -1612,7 +1630,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E } let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() }; let mut carry = Carry::new(); // Read the page's streams as the one stream they are, in one pass, as rendering and editing @@ -2235,7 +2253,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let p = page_dict(doc, page)?; let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?; @@ -2440,7 +2458,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied())); let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?; @@ -2661,7 +2679,7 @@ fn rewrite_block_from( { return rewrite_styled_block(doc, page, &b, &lines, text, style); } - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; // Explicit line breaks stay (#1443); within a line, runs of white space become one space. @@ -3044,7 +3062,7 @@ pub(crate) fn selectable_blocks( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_dict(doc, page)?; - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let lines = text_lines(doc, page)?; Ok(group_blocks(&lines) .into_iter() @@ -3133,7 +3151,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize return Ok(()); } let p = page_dict(doc, page)?; - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let lines = text_lines(doc, page)?; let blocks = group_blocks(&lines); let mut selected = std::collections::BTreeSet::new(); diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 928280d5d..a77559494 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -772,7 +772,7 @@ pub(super) fn rewrite_styled_block( row_ranges.push(row_start..row_start + row.len()); rows.push(row); } - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and From 735c80c6c51975b6ba57ec2fa8ced1768bbba848 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:22:55 +0000 Subject: [PATCH 027/142] fix(cos): DecodeBudget decodes within a per-stream bound too Callers with a tighter limit for one stream (an embedded font program) need it alongside the shared allowance. A decode that runs past that bound charges the bound, the work it took, so repeated failures still spend the allowance. --- crates/cos/src/budget.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index cf1c3fc2a..f88971222 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -53,19 +53,35 @@ impl DecodeBudget { /// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without /// decoding, once the allowance is spent. pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { - self.charge(stream, Stream::decoded_within_typed) + self.charge(stream, usize::MAX, Stream::decoded_within_typed) + } + + /// Like [`DecodeBudget::decode`], for a stream with a tighter bound of its own: decoding + /// past `max` bytes is an error that charges `max`, the work it took to find out. + pub fn decode_within(&mut self, stream: &Stream, max: usize) -> Result, CosError> { + self.charge(stream, max, Stream::decoded_within_typed) } /// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to /// the allowance. pub fn decode_strict(&mut self, stream: &Stream) -> Result, CosError> { - self.charge(stream, Stream::decoded_strict_within_typed) + self.charge(stream, usize::MAX, Stream::decoded_strict_within_typed) } - fn charge(&mut self, stream: &Stream, decode: fn(&Stream, usize) -> Result, FilterError>) -> Result, CosError> { + fn charge(&mut self, stream: &Stream, max: usize, decode: fn(&Stream, usize) -> Result, FilterError>) -> Result, CosError> { if self.bytes == 0 && !stream.raw.is_empty() { return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } + if max < self.bytes { + let result = decode(stream, max); + let spent = match &result { + Ok(data) => data.len(), + Err(FilterError::LimitExceeded(_)) => max, + Err(_) => 0, + }; + self.bytes = self.bytes.saturating_sub(spent); + return result.map_err(|e| CosError::Filter(e.to_string())); + } match decode(stream, self.bytes) { Ok(data) => { self.bytes = self.bytes.saturating_sub(data.len()); @@ -142,6 +158,12 @@ mod tests { assert!(budget.decode_strict(stream).is_err()); assert_eq!(budget.remaining(), 0); + let mut budget = DecodeBudget::new(10 << 20, 1); + assert!(budget.decode_within(stream, 1000).is_err()); + assert_eq!(budget.remaining(), (10 << 20) - 1000); + assert_eq!(budget.decode_within(stream, 2 << 20).map(|d| d.len()).ok(), Some(1 << 20)); + assert_eq!(budget.remaining(), (9 << 20) - 1000); + assert!(budget.enter_form()); assert!(!budget.enter_form()); assert!(budget.first_visit(bomb)); From 7a33a85d8b95b98fb1310258f1e104bd4287ecf4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:24:00 +0000 Subject: [PATCH 028/142] fix(fonts): embedded sfnt reads can share one decode budget embedded_sfnt decoded each program with a fresh 16 MiB allowance and charged nothing when the program turned out unusable, so a page whose font resources all named one bad program inflated it once per alias. embedded_sfnt_within charges every decode to a caller's DecodeBudget. --- crates/fonts/src/pdf.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0aaed6939..199211665 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -14,7 +14,7 @@ use std::borrow::Cow; use std::collections::{BinaryHeap, HashMap, HashSet}; -use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; /// How the bytes of a string map to codes. #[derive(Clone, Debug, PartialEq)] @@ -92,6 +92,15 @@ fn legacy_indic_font(base_font: &str) -> bool { /// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export /// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer. pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { + embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0)) +} + +/// The largest embedded sfnt program read for the editor. +const MAX_SFNT: usize = 16 << 20; + +/// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares: +/// each program decoded is charged whether or not it turns out to be usable. +pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option> { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref()); let descriptor = dict(doc, descendant.as_ref().unwrap_or(font).get(b"FontDescriptor"))?; @@ -99,7 +108,7 @@ pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(mut bytes) = stream.decoded_within(16 << 20) else { continue }; + let Ok(mut bytes) = budget.decode_within(stream, MAX_SFNT) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 { continue; @@ -2114,4 +2123,20 @@ mod encoding_tests { assert_eq!(m.text_of(0x61).as_deref(), Some("a")); assert_eq!(m.decode(b"abc"), "abc"); } + + #[test] + fn unusable_programs_still_spend_the_decode_budget() { + // 128 resources naming one 1 MiB program that isn't a font: each decode is charged, + // so an enumeration stops decoding once the shared budget is spent. + let mut doc = Document::new_empty(); + let program = doc.add(pdfcraft_cos::Stream::flate(Dict::new(), &vec![0; 1 << 20])); + let descriptor: Dict = [(b"FontFile2".to_vec(), Object::Ref(program))].into_iter().collect(); + let descriptor = doc.add(descriptor); + let font: Dict = [(b"FontDescriptor".to_vec(), Object::Ref(descriptor))].into_iter().collect(); + let mut budget = DecodeBudget::new(64 << 20, 0); + for _ in 0..128 { + assert!(embedded_sfnt_within(&doc, &font, &mut budget).is_none()); + } + assert_eq!(budget.remaining(), 0); + } } From c88831381e14ab461ea5cc96881a48e88d5d0d87 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:24:00 +0000 Subject: [PATCH 029/142] fix(edit): editor font enumeration shares one decode budget text_editor_fonts counted only the programs it kept against its 32 MiB budget, so up to 128 aliases of one unusable program were each inflated in full. Every decode now spends a shared 64 MiB allowance. --- crates/edit/src/text.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 5784b95f3..34768527b 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -113,10 +113,12 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result budget { continue; } From 37b6c530d82d4351f3598010fd5262e98bbb6882 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:25:04 +0000 Subject: [PATCH 030/142] refactor(edit): images read page content through content_streams The image module kept its own copy of the page-content reader; the one in text carries the page decode budget. --- crates/edit/src/images.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 0f75ff3d3..1f7773a52 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -9,6 +9,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use std::sync::Arc; use crate::EditError; +use crate::text::content_streams; /// An image drawn on a page. #[derive(Clone, Debug, PartialEq)] @@ -73,10 +74,6 @@ struct ClipPath { even_odd: bool, } -fn streams(doc: &Document, page: &Dict) -> Result)>, EditError> { - crate::text::content_streams(doc, page) -} - /// Image draws one page's inventory lists at most: a form drawn many times from other forms /// would otherwise multiply a few hundred bytes into millions of records. pub(crate) const MAX_PAGE_IMAGES: usize = 100_000; @@ -230,7 +227,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr let mut out = Vec::new(); // The streams are one stream in pieces, so the graphics state and operators carry over. let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let pieces = joined(&streams(doc, &p.dict)?); + let pieces = joined(&content_streams(doc, &p.dict)?); for (i, op) in pieces.parse().ops.iter().enumerate() { state.apply(op)?; let ctm = state.placement.matrix; @@ -381,7 +378,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() }; - let pieces = joined(&streams(doc, &p.dict)?); + let pieces = joined(&content_streams(doc, &p.dict)?); walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) } @@ -482,7 +479,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let images = page_images(doc, page)?; let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict)?; + let all = content_streams(doc, &p.dict)?; let pieces = joined(&all); let ops = pieces.parse().ops; if !ops.get(img.op).is_some_and(|op| op.is("Do")) { @@ -530,7 +527,7 @@ pub(crate) fn selectable_images( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_of(doc, page)?; - let all = streams(doc, &p.dict)?; + let all = content_streams(doc, &p.dict)?; Ok(page_images(doc, page)? .into_iter() .enumerate() @@ -754,7 +751,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize } let images = page_images(doc, page)?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict)?; + let all = content_streams(doc, &p.dict)?; let mut plans = std::collections::BTreeMap::new(); for index in indexes { let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?; From 986a64bc77a2c4725b72e06d057475833ab4737b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:05:32 +0000 Subject: [PATCH 031/142] fix(cos): cap the objects one parsed object may build The parser pushed every array element and dictionary value with only a nesting limit, so a small compressed object stream decoding to a long run of '0 ' became tens of millions of Object values. Each top-level parse now counts the objects it builds and fails past 4M, including inside tolerated junk. --- crates/cos/src/parser.rs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 045805596..c8b90106c 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -10,6 +10,9 @@ use crate::object::{Dict, ObjRef, Object, PdfString, Stream}; use crate::{Bytes, CosError}; const MAX_DEPTH: usize = 128; +/// Objects one top-level [`Lexer::object`] call may build. Real arrays (page trees, parent +/// trees, widths) stay far below; a decompressed object stream of `0 0 0 ...` does not. +const MAX_NODES: usize = 1 << 22; /// One of the six PDF whitespace characters (ISO 32000-2 §7.2.3, Table 1). pub fn is_whitespace(b: u8) -> bool { @@ -28,11 +31,13 @@ fn is_regular(b: u8) -> bool { pub struct Lexer<'a> { pub data: &'a [u8], pub pos: usize, + /// Objects built by the current top-level [`Lexer::object`] call. + nodes: usize, } impl<'a> Lexer<'a> { pub fn new(data: &'a [u8], pos: usize) -> Self { - Self { data, pos: pos.min(data.len()) } + Self { data, pos: pos.min(data.len()), nodes: 0 } } fn peek(&self) -> Option { @@ -84,13 +89,22 @@ impl<'a> Lexer<'a> { /// Parse one direct object. References `N G R` are recognised. pub fn object(&mut self) -> Result { + self.nodes = 0; self.object_depth(0) } + fn over_budget(&self) -> bool { + self.nodes > MAX_NODES + } + fn object_depth(&mut self, depth: usize) -> Result { if depth > MAX_DEPTH { return Err(self.err("nesting too deep")); } + self.nodes = self.nodes.saturating_add(1); + if self.over_budget() { + return Err(self.err("object too large")); + } self.skip_ws(); let Some(b) = self.peek() else { return Err(self.err("unexpected end of data")) }; match b { @@ -125,6 +139,7 @@ impl<'a> Lexer<'a> { let before = self.pos; match self.object_depth(depth + 1) { Ok(o) => items.push(o), + Err(e) if self.over_budget() => return Err(e), // Tolerate junk inside arrays by skipping one byte. Err(_) if self.pos == before => self.pos += 1, Err(e) => return Err(e), @@ -283,6 +298,9 @@ impl<'a> Lexer<'a> { // Tolerate garbage between entries (seen in damaged files): skip a token. let before = self.pos; let _ = self.object_depth(depth + 1); + if self.over_budget() { + return Err(self.err("object too large")); + } if self.pos == before { self.pos += 1; } @@ -598,4 +616,20 @@ mod tests { let deep = "[".repeat(10_000); assert!(Lexer::new(deep.as_bytes(), 0).object().is_err()); } + + #[test] + fn one_object_has_a_node_budget() { + // A wide array (from a tiny compressed object stream) must not grow without bound. + let wide = |n: usize| format!("[{}]", "0 ".repeat(n)); + let at_cap = Lexer::new(wide(MAX_NODES - 1).as_bytes(), 0).object(); + assert!(matches!(at_cap, Ok(Object::Array(ref items)) if items.len() == MAX_NODES - 1)); + assert!(Lexer::new(wide(MAX_NODES).as_bytes(), 0).object().is_err()); + // The same inside a dictionary value and behind tolerated junk. + assert!(Lexer::new(format!("<< /A {} >>", wide(MAX_NODES)).as_bytes(), 0).object().is_err()); + assert!(Lexer::new(format!("[[{}] ]", "0 ".repeat(MAX_NODES)).as_bytes(), 0).object().is_err()); + // The budget is per top-level object: a lexer reused for many operands keeps working. + let two = wide(MAX_NODES - 1).repeat(2); + let mut lx = Lexer::new(two.as_bytes(), 0); + assert!(lx.object().is_ok() && lx.object().is_ok()); + } } From e07942c8294301cf0ee5ad911d7dc3016bd33c50 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:01 +0000 Subject: [PATCH 032/142] fix(annot): cap the bumps of a cloudy outline The cloud border clamped bumps per edge only, so a polygon with many long edges asked for 10,000 cubic segments on each one. The bump radius now grows so the whole outline draws at most 20,000 bumps plus one per edge. --- crates/annot/src/appearance.rs | 6 ++++++ crates/annot/src/tests.rs | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 4668881a6..59cacbbdd 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -120,12 +120,18 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String { smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect() } +/// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow. +pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000; + /// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of /// half-circle bumps of about `r`, bulging outwards. pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String { let area: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.0 * b.1 - b.0 * a.1).sum(); // Outward normal of an edge: to the right of travel for counter-clockwise outlines. let sign = if area >= 0.0 { 1.0 } else { -1.0 }; + let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum(); + // Each edge rounds to at least one bump, so the total stays within the budget plus one per edge. + let r = r.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64)); let mut c = String::new(); for (i, (a, b)) in pts.iter().zip(pts.iter().cycle().skip(1)).enumerate() { let (dx, dy) = (b.0 - a.0, b.1 - a.1); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a65996681..201fe0e08 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1504,3 +1504,11 @@ fn typed_signature_uses_nonzero_winding_fill() { assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}"); assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); } + +#[test] +fn cloud_outline_has_a_bump_budget() { + // 50 long edges would each ask for 10,000 bumps of radius 1. + let pts: Vec<(f64, f64)> = (0..50).map(|i| (f64::from(i) * std::f64::consts::TAU / 50.0).sin_cos()).map(|(s, c)| (1e7 * c, 1e7 * s)).collect(); + let bumps = appearance::cloud_path(&pts, 1.0).lines().filter(|l| l.ends_with(" c")).count(); + assert!((pts.len()..=appearance::MAX_CLOUD_BUMPS + 2 * pts.len()).contains(&bumps), "{bumps} bumps"); +} From 1695106f98e044cab8892c3b048ad6155f316027 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:01 +0000 Subject: [PATCH 033/142] fix(annot): list a repeated link once and cap its web-address text The link listing copied the action's URI into a new String for every /Annots entry, so one annotation referenced many times, or many small links sharing one long URI string, multiplied that string in memory. The listing skips annotation references it has already listed and keeps at most 16 MiB of web-address text; a link past that lists as an uneditable action, which leaves it unchanged in the file. --- crates/annot/src/links.rs | 29 ++++++++++++++++++++++++----- crates/annot/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 4d3eef632..b6a2c7575 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -78,11 +78,14 @@ pub struct LinkItem { pub style: LinkStyle, } +/// Web-address text one listing keeps; past it a link lists as [`LinkAction::Other`]. +pub(crate) const MAX_URI_BYTES: usize = 16 << 20; + fn nums(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() } -fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction { +fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction { let dest_page = |dest: &Object| -> Option { let a = doc.resolve(dest); let first = a.as_array()?.first()?.clone(); @@ -97,9 +100,17 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction { } let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) }; match a.name(b"S") { - Some(b"URI") => LinkAction::Uri( - a.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned())).unwrap_or_default(), - ), + Some(b"URI") => { + let uri = a.get(b"URI").map(|u| doc.resolve(u)); + let bytes = uri.as_ref().and_then(|u| u.as_string()).map_or(&[][..], |s| &s.bytes[..]); + match uri_budget.checked_sub(bytes.len()) { + Some(left) => { + *uri_budget = left; + LinkAction::Uri(String::from_utf8_lossy(bytes).into_owned()) + } + None => LinkAction::Other("web address too long".into()), + } + } Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page), Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()), None => LinkAction::Other("no action".into()), @@ -133,8 +144,16 @@ fn style_of(doc: &Document, d: &Dict) -> LinkStyle { pub fn list(doc: &Document) -> Vec { let Ok(pages) = page_refs(doc) else { return Vec::new() }; let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut uri_budget = MAX_URI_BYTES; for (pi, p) in pages.iter().enumerate() { for (k, a) in annots(doc, *p).iter().enumerate() { + // One annotation object listed under several entries or pages is one link. + if let Some(r) = a.as_ref() + && !seen.insert(r) + { + continue; + } let Some(d) = doc.resolve(a).as_dict().cloned() else { continue }; if d.name(b"Subtype") != Some(b"Link") { continue; @@ -148,7 +167,7 @@ pub fn list(doc: &Document) -> Vec { index: k, obj: a.as_ref(), rect: [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])], - action: action_of(doc, &d, &pages), + action: action_of(doc, &d, &pages, &mut uri_budget), style: style_of(doc, &d), }); } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 201fe0e08..c52732ea8 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1505,6 +1505,42 @@ fn typed_signature_uses_nonzero_winding_fill() { assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); } +fn doc_of(objs: &[String]) -> Document { + 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()); + Document::open(Arc::new(out)).expect("opens") +} + +#[test] +fn link_listing_skips_repeated_refs_and_caps_web_address_text() { + // One link referenced 1000 times, and 300 small links sharing one 64 KiB address. + let annots = format!("{}{}", " 4 0 R".repeat(1000), "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >>".repeat(300)); + let doc = doc_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A 5 0 R >>".into(), + "<< /S /URI /URI 6 0 R >>".into(), + format!("({})", "u".repeat(64 << 10)), + ]); + let all = links::list(&doc); + assert_eq!(all.iter().filter(|l| l.obj == Some(ObjRef::new(4, 0))).count(), 1); + assert_eq!(all.len(), 301, "every distinct link is still listed"); + let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum(); + assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept"); + assert!(matches!(&all[0].action, links::LinkAction::Uri(u) if u.len() == 64 << 10)); +} + #[test] fn cloud_outline_has_a_bump_budget() { // 50 long edges would each ask for 10,000 bumps of radius 1. From 83d8eb085603a6511afca93b82ec1bb42a03eed9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:44 +0000 Subject: [PATCH 034/142] fix(create): decode an indexed lookup table within a small cap Image export decoded a stream lookup table under the general 1 GiB stream limit although a table holds at most 1024 bytes, and every image sharing the palette decoded it again. The table now decodes within 64 KiB and a larger one is refused for that image. --- crates/create/src/extract.rs | 7 ++++++- crates/create/src/tests.rs | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 768da4d1f..b1aa5e5b1 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -359,6 +359,9 @@ impl Space { } } +/// A lookup table holds at most 256 entries of 4 bytes; decoding stops well past that. +const MAX_LOOKUP: usize = 64 << 10; + fn space(doc: &Document, cs: Option<&Object>) -> Result { let Some(cs) = cs else { return Err("the image has no colour space".into()) }; let cs = doc.resolve(cs); @@ -393,7 +396,9 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result { }; let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() { Some(Object::String(s)) => s.bytes.clone(), - Some(Object::Stream(t)) => t.decoded().map_err(|e| e.to_string())?, + Some(Object::Stream(t)) => { + t.decoded_within(MAX_LOOKUP).map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())? + } _ => return Err("an indexed colour space without a lookup table".into()), }; Ok(Space::Indexed(Box::new(base), hival, table)) diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 635610569..131cb10d2 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -832,6 +832,15 @@ fn indexed_hival_resolves_indirect_values_and_stream_palettes() { } } +#[test] +fn indexed_lookup_stream_has_a_size_cap() { + // A lookup table needs 256 × 4 bytes at most; a 1 MiB one is refused, not decoded whole. + let mut palette = vec![255, 0, 0, 0, 0, 255]; + palette.resize(1 << 20, 0); + let (doc, image) = indexed_export_fixture(Object::Int(1), palette, true, false, 8, vec![0, 1, 0, 1]); + assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("lookup table")); +} + #[test] fn indexed_hival_boundary_values_keep_valid_palette_entries() { let palette: Vec = (0..=255u8).flat_map(|i| [i, 255 - i, 17]).collect(); From 05df6ee4b722943a7b28ccadcb9bc9950d82480d Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:10:15 +0000 Subject: [PATCH 035/142] fix(fonts): decode each candidate font program once, within its size Looking for an already embedded full program decoded every candidate up to 17 MiB, once per resource naming it, so a page whose PCFull resources all alias one compressed stream, or many marked streams, made one text edit inflate gigabytes. Candidates now decode within the program's own length, each once per embedding, and at most 16 in all; the resource check compares its CMap streams within their expected lengths too. --- crates/fonts/src/embedding.rs | 140 ++++++++++++++++++++++++++-------- 1 file changed, 108 insertions(+), 32 deletions(-) diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index a5bb011e0..23f34dbb3 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -1,9 +1,10 @@ //! Unicode embedding of an explicitly supplied, embeddable TrueType face. Font files are //! document inputs or the optional craft-fonts build input, never system-interface fallbacks. +use std::collections::HashMap; use std::fmt::Write as _; -use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use skrifa::{ FontRef, MetadataProvider, instance::{LocationRef, Size}, @@ -173,7 +174,8 @@ pub fn embed_mapping( return Err("the font family name is invalid".into()); } let glyph_map = if cff { cid_encoding.as_bytes() } else { gids.as_slice() }; - if let Some(resource) = same_resource(doc, fonts, &name, bytes, glyph_map, cmap.as_bytes()) { + let mut programs = ProgramMatch::new(bytes); + if let Some(resource) = same_resource(doc, fonts, &name, &mut programs, glyph_map, cmap.as_bytes()) { return Ok(resource); } let mut descriptor = Dict::new(); @@ -193,12 +195,7 @@ pub fn embed_mapping( ); // Reuse the program another resource of this page already has, else one registered on another // page; source subset programs stay untouched. - let existing = written(doc, fonts).find(|w| w.has_program(doc, bytes)).map(|w| w.program).or_else(|| { - doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() { - Object::Stream(s) if is_program(doc, s, bytes) => Some(Object::Ref(r)), - _ => None, - }) - }); + let existing = existing_program(doc, fonts, &mut programs); let program = existing.unwrap_or_else(|| { let mut d = Dict::new(); d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64)); @@ -246,12 +243,64 @@ pub fn embed_mapping( Ok(resource) } -/// The decoded data of the stream `object` refers to. -fn stream_data(doc: &Document, object: Option<&Object>) -> Option> { - match doc.resolve(object?).as_ref() { - Object::Stream(s) => s.decoded_within(17 << 20).ok(), - _ => None, +/// Whether the stream `object` refers to decodes to `expected`. +fn stream_is(doc: &Document, object: Option<&Object>, expected: &[u8]) -> bool { + object.is_some_and(|o| match doc.resolve(o).as_ref() { + Object::Stream(s) => s.decoded_within(expected.len()).is_ok_and(|d| d == expected), + _ => false, + }) +} + +/// Full programs one embedding decodes at most while looking for its own. A real match passes +/// the cheap checks and decodes once; many candidates that pass them come from a hostile file. +const MAX_PROGRAM_DECODES: usize = 16; + +/// Recognises the full program written for `bytes`: each candidate stream decodes at most once +/// and within `bytes.len()`, and at most [`MAX_PROGRAM_DECODES`] decode in all. +struct ProgramMatch<'a> { + bytes: &'a [u8], + known: HashMap, + decodes: usize, +} + +impl<'a> ProgramMatch<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, known: HashMap::new(), decodes: 0 } } + + /// Whether `r`, holding `object`, is the program. + fn is(&mut self, doc: &Document, r: ObjRef, object: &Object) -> bool { + if let Some(&known) = self.known.get(&r) { + return known; + } + let found = match object { + Object::Stream(s) + if s.dict.get(b"PCFullFont").is_some() + && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(self.bytes.len()).ok() + && self.decodes < MAX_PROGRAM_DECODES => + { + self.decodes = self.decodes.saturating_add(1); + s.decoded_within(self.bytes.len()).is_ok_and(|data| data == self.bytes) + } + _ => false, + }; + self.known.insert(r, found); + found + } + + /// Whether the resource's program is this one. + fn written(&mut self, doc: &Document, w: &Written) -> bool { + w.program.as_ref().is_some_and(|r| self.is(doc, r, &*doc.get(r))) + } +} + +/// The program for `programs.bytes` that this page's `PCFull` resources, or else the first +/// objects of the document, already have. +fn existing_program(doc: &Document, fonts: &Dict, programs: &mut ProgramMatch) -> Option { + written(doc, fonts) + .find(|w| programs.written(doc, w)) + .map(|w| w.program) + .or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &*o).then_some(Object::Ref(r)))) } /// A `PCFull` resource that [`embed_mapping`] wrote. @@ -263,21 +312,6 @@ struct Written { program: Object, } -impl Written { - /// Whether its program is `bytes`. - fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool { - matches!(doc.resolve(&self.program).as_ref(), Object::Stream(s) if is_program(doc, s, bytes)) - } -} - -/// Whether `s` is a full program written for `bytes`, compressed or not. The length it was -/// written with is compared before decoding. -fn is_program(doc: &Document, s: &Stream, bytes: &[u8]) -> bool { - s.dict.get(b"PCFullFont").is_some() - && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok() - && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes) -} - /// The `PCFull` resources in `fonts`, in order. fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator + 'a { fonts.iter().filter(|(key, _)| key.starts_with(b"PCFull")).take(256).filter_map(move |(key, value)| { @@ -291,14 +325,14 @@ fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator Option { +fn same_resource(doc: &Document, fonts: &Dict, name: &str, programs: &mut ProgramMatch, glyph_map: &[u8], unicode: &[u8]) -> Option { written(doc, fonts) .find(|w| { let glyphs = w.cid.get(b"CIDToGIDMap").or_else(|| w.top.get(b"Encoding")); w.top.name(b"BaseFont") == Some(name.as_bytes()) - && stream_data(doc, w.top.get(b"ToUnicode")).is_some_and(|d| d == unicode) - && stream_data(doc, glyphs).is_some_and(|d| d == glyph_map) - && w.has_program(doc, bytes) + && stream_is(doc, w.top.get(b"ToUnicode"), unicode) + && stream_is(doc, glyphs, glyph_map) + && programs.written(doc, w) }) .map(|w| w.resource) } @@ -385,6 +419,48 @@ mod tests { assert_eq!(programs(&doc), 2); } + #[test] + fn the_program_search_decodes_each_candidate_once_and_few_in_all() { + let bytes = b"the program".as_slice(); + let decoy = |doc: &mut Document| { + let mut d = Dict::new(); + d.set(b"PCFullFont".to_vec(), Object::Bool(true)); + d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64)); + doc.add(Object::Stream(Stream::flate(d, &[0; 4096]))) + }; + let resource = |doc: &mut Document, program: ObjRef| { + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut cid = Dict::new(); + cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + let mut top = Dict::new(); + top.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid)])); + doc.add(Object::Dict(top)) + }; + // 200 resources sharing one decoy program decode it once. + let mut doc = Document::new_empty(); + let shared = decoy(&mut doc); + let mut fonts = Dict::new(); + for i in 0..200 { + let r = resource(&mut doc, shared); + fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r)); + } + let mut programs = ProgramMatch::new(bytes); + assert_eq!(existing_program(&doc, &fonts, &mut programs), None); + assert_eq!(programs.decodes, 1); + // 100 distinct decoys decode a bounded number of times. + let mut doc = Document::new_empty(); + let mut fonts = Dict::new(); + for i in 0..100 { + let program = decoy(&mut doc); + let r = resource(&mut doc, program); + fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r)); + } + let mut programs = ProgramMatch::new(bytes); + assert_eq!(existing_program(&doc, &fonts, &mut programs), None); + assert_eq!(programs.decodes, MAX_PROGRAM_DECODES); + } + #[test] fn the_full_program_is_written_compressed_and_another_page_reuses_it() { let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); From 8da1164f7d303487d3c90861bc2994e9ab89f5e0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:10:15 +0000 Subject: [PATCH 036/142] fix(fonts): decode Encoding and ToUnicode CMaps within 16 MiB Both CMap streams decoded under the general 1 GiB stream limit, so a small Flate stream of whitespace inflated to a gigabyte that was then lexed on every font load. They now decode within 16 MiB, like the font programs beside them, and a larger CMap is not read. --- crates/fonts/src/pdf.rs | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0aaed6939..fb524f65e 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -275,6 +275,9 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20; /// Most codespace or CID ranges kept from a CMap; Adobe's largest published CMaps have a few /// thousand. const MAX_CMAP_RANGES: usize = 1 << 16; +/// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger +/// one is not decoded at all. +const MAX_CMAP_BYTES: usize = 16 << 20; /// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range /// costs one record however many codes it covers; where two overlap, the one declared later wins. @@ -679,7 +682,7 @@ impl Metrics { m.codes = Codes::Two; if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e)) && let Object::Stream(s) = &*e - && let Ok(data) = s.decoded() + && let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) { let (spaces, cids) = parse_cmap(&data); if !spaces.is_empty() { @@ -1220,7 +1223,7 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { } match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() { Some(Object::Stream(s)) => { - if let Ok(data) = s.decoded() { + if let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) { parse_to_unicode(&data, &mut out); } } @@ -1496,6 +1499,38 @@ mod tests { assert_eq!(m.encode("abc"), None); } + #[test] + fn cmap_streams_decode_within_a_size_cap() { + // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole. + let to_unicode = b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange"; + let encoding = b"1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange"; + for padded in [false, true] { + let mut doc = Document::new_empty(); + let mut stream = |cmap: &[u8]| { + let mut data = cmap.to_vec(); + if padded { + data.resize(MAX_CMAP_BYTES + 1, b' '); + } + Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &data)))) + }; + let (tu, enc) = (stream(to_unicode), stream(encoding)); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", enc), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", tu), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, 0x24]) == "A", !padded, "ToUnicode, padded: {padded}"); + assert_eq!(matches!(m.codes, Codes::Ranges(_)), !padded, "Encoding, padded: {padded}"); + } + } + #[test] fn composite_fonts_use_to_unicode() { let mut doc = Document::new_empty(); From a16661ac102e921524459bc991abb4473115ab2f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:11:27 +0000 Subject: [PATCH 037/142] fix(measure): list a repeated measurement once and cap what a listing keeps The listing read a measurement for every /Annots entry, so one annotation referenced many times, or many small annotations sharing one indirect 2048-point vertex array, multiplied its points and text in memory. The listing skips annotation references it has already read and stops, marked truncated, once the kept points and text pass 32 MiB. --- crates/measure/src/lib.rs | 29 +++++++++++++++++++++++++++-- crates/measure/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index 72b6c4b07..4843d0fb6 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -448,10 +448,20 @@ pub struct Unsupported { pub struct Listing { pub measurements: Vec, pub unsupported: Vec, - /// The annotation limit stopped enumeration early. + /// The annotation or retained-size limit stopped enumeration early. pub truncated: bool, } const MAX_ANNOTATIONS: usize = 100_000; +/// Points and text one listing keeps, as counted by [`retained`]: about a thousand +/// measurements of [`MAX_POINTS`] points each. +const MAX_LISTED_BYTES: usize = 32 << 20; +/// The bytes a listed measurement keeps in points and text. +fn retained(m: &Measurement) -> usize { + let s = &m.scale; + [m.id.len(), m.author.len(), m.label.len(), s.unit.len(), s.area_unit.len(), s.ratio.len()] + .into_iter() + .fold(m.points.len().saturating_mul(std::mem::size_of::()), usize::saturating_add) +} fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: usize) -> Result { let scale = Scale::read(doc, d.get(b"Measure").ok_or_else(|| invalid("measurement has no scale"))?)?; let key = if kind == Kind::Distance { &b"L"[..] } else { b"Vertices" }; @@ -471,6 +481,8 @@ fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: us pub fn list(doc: &Document) -> Listing { let mut out = Listing::default(); let mut count = 0usize; + let mut kept = 0usize; + let mut seen = std::collections::HashSet::new(); for (page, p) in pdfcraft_model::pages(doc).iter().enumerate() { let Some(o) = p.dict.get(b"Annots") else { continue }; let o = doc.resolve(o); @@ -481,6 +493,12 @@ pub fn list(doc: &Document) -> Listing { out.truncated = true; return out; } + // One annotation object under several entries or pages is one measurement. + if let Some(r) = a.as_ref() + && !seen.insert(r) + { + continue; + } let o = doc.resolve(a); let Some(d) = o.as_dict() else { continue }; let kind = match d.name(b"IT") { @@ -490,7 +508,14 @@ pub fn list(doc: &Document) -> Listing { _ => continue, }; match read_measurement(doc, d, kind, page, index) { - Ok(m) => out.measurements.push(m), + Ok(m) => { + kept = kept.saturating_add(retained(&m)); + if kept > MAX_LISTED_BYTES { + out.truncated = true; + return out; + } + out.measurements.push(m); + } Err(e) => out.unsupported.push(Unsupported { page, index, reason: e.to_string() }), } } diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 303f9b438..315fedc93 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -403,3 +403,23 @@ fn a_scale_ratio_converts_its_page_side_to_points() { assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}"); } } +#[test] +fn listing_skips_repeated_refs_and_caps_what_it_keeps() { + let mut d = fixture("", "", &[]); + let points: Vec = (0..MAX_POINTS).map(|i| [i as f64 * 0.1, 0.0]).collect(); + add(&mut d, &new(Kind::Perimeter, points), &Meta::default()).unwrap(); + let (r, mut annotation) = annot(&d, 0, 0).unwrap(); + let page = pdfcraft_model::pages(&d)[0].obj; + let set_annots = |d: &mut Document, list: Vec| d.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(list))).unwrap(); + // One annotation referenced 1000 times is one measurement. + set_annots(&mut d, vec![Object::Ref(r); 1000]); + assert_eq!(list(&d).measurements.len(), 1); + // Many small annotations sharing one 2048-point vertex array stop at the retained budget. + let vertices = d.add(annotation.get(b"Vertices").unwrap().clone()); + annotation.set(b"Vertices".to_vec(), Object::Ref(vertices)); + set_annots(&mut d, vec![Object::Dict(annotation); 1200]); + let listing = list(&d); + assert!(listing.truncated); + assert!(listing.measurements.iter().map(retained).sum::() <= MAX_LISTED_BYTES); + assert!(!listing.measurements.is_empty()); +} From a34697a8eb8d67088543effce1411a85cb0604ed Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:12:31 +0000 Subject: [PATCH 038/142] fix(measure): a failed content decode uses up the snap byte budget Snap geometry charged decoded bytes only on success, so a /Contents array repeating one stream that inflates past the 16 MiB limit ran that inflation for every entry without spending any budget. A tolerant decode fails only on an unsupported filter or after producing the remaining budget, so a failure now spends the rest of the budget and later parts are skipped as truncated. --- crates/measure/src/snap.rs | 12 +++++++++--- crates/measure/src/tests.rs | 13 +++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index 3cf6bbd17..37e64e8b8 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -214,14 +214,14 @@ impl Walker<'_> { self.geometry.truncated = true; break; } - // An undecodable part is skipped; the rest of the page still snaps. + // An undecodable part is skipped; the parts before it still snap. match s.decoded_within(remaining) { Ok(data) => { joined.extend(data); // A space, as the renderer joins pieces: a trailing comment runs on. joined.push(b' '); } - Err(_) => self.geometry.unreadable = self.geometry.unreadable.saturating_add(1), + Err(_) => self.decode_failed(remaining), } } } @@ -236,11 +236,17 @@ impl Walker<'_> { match stream.decoded_within(remaining) { Ok(data) => self.ops(&data, resources, initial, depth), Err(_) => { - self.geometry.unreadable = self.geometry.unreadable.saturating_add(1); + self.decode_failed(remaining); Ok(()) } } } + /// A tolerant decode fails on an unsupported filter or once it has produced `remaining` bytes, + /// so a failure uses up the byte budget: repeating a failing stream cannot repeat that work. + fn decode_failed(&mut self, remaining: usize) { + self.geometry.unreadable = self.geometry.unreadable.saturating_add(1); + self.bytes = self.bytes.saturating_add(remaining); + } fn ops(&mut self, data: &[u8], resources: &Dict, initial: Matrix, depth: usize) -> Result<()> { self.bytes = self.bytes.saturating_add(data.len()); self.streams = self.streams.saturating_add(1); diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 315fedc93..e9722cb62 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -423,3 +423,16 @@ fn listing_skips_repeated_refs_and_caps_what_it_keeps() { assert!(listing.measurements.iter().map(retained).sum::() <= MAX_LISTED_BYTES); assert!(!listing.measurements.is_empty()); } +#[test] +fn a_stream_over_the_byte_budget_uses_it_up_once() { + // 50 references to one Flate stream that inflates past the 16 MiB budget decode once. + let big = pdfcraft_cos::Stream::flate(Dict::new(), &vec![b' '; 17 << 20]); + let hex: String = big.raw.iter().map(|b| format!("{b:02x}")).collect(); + let bomb = String::from_utf8(pdfcraft_testkit::stream_obj("/Filter [/ASCIIHexDecode /FlateDecode]", hex)).unwrap(); + let contents = format!("[4 0 R{}]", " 5 0 R".repeat(50)); + let d = fixture_with(&contents, "0 0 m 10 0 l S", "", &[bomb.as_str()]); + let g = snap::geometry(&d, 0).unwrap(); + assert_eq!(g.unreadable, 1); + assert!(g.truncated); + assert_eq!(g.segments.len(), 1, "the parts before it still snap"); +} From 853a9ae1e33f9c906df7084d20a12eaf5c92ebe4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:13:06 +0000 Subject: [PATCH 039/142] fix(preflight): decode an output-intent profile within 16 MiB Reading the colour space from an ICC header when /N is missing decoded the whole profile under the general 1 GiB stream limit to look at four bytes. The profile now decodes within 16 MiB; a larger one leaves the component count unknown. --- crates/preflight/src/lib.rs | 5 ++++- crates/preflight/src/tests.rs | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 2cda29fea..ce17a4a8d 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -214,6 +214,9 @@ fn has_pdfa_intent(doc: &Document) -> bool { pdfa_intent_profile(doc).is_some() } +/// Largest output-intent profile decoded to read its header; real ICC profiles stay well under it. +const MAX_ICC_PROFILE: usize = 16 << 20; + /// The PDF/A output intent's destination profile: `Some(n)` with the profile's number of colour /// components (`/N`, else the ICC header's data colour space; `None` when neither says), or /// `None` when the document has no PDF/A output intent. @@ -229,7 +232,7 @@ fn pdfa_intent_profile(doc: &Document) -> Option> { let Object::Stream(s) = &*profile else { return Some(None) }; Some(s.dict.int(b"N").or_else(|| { // ICC.1 header: the data colour space is bytes 16–19. - match s.decoded().ok()?.get(16..20)? { + match s.decoded_within(MAX_ICC_PROFILE).ok()?.get(16..20)? { b"GRAY" => Some(1), b"RGB " => Some(3), b"CMYK" => Some(4), diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 80b1c63c4..efd55f28e 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -417,3 +417,15 @@ fn oversized_xmp_is_refused_while_decoding() { assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress"); assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding"); } + +#[test] +fn an_output_intent_profile_header_is_read_within_a_size_cap() { + let intent = "/OutputIntents [<< /S /GTS_PDFA1 /DestOutputProfile 2 0 R >>]"; + for (size, n) in [(128, Some(4)), (MAX_ICC_PROFILE + 1, None)] { + let mut doc = build(&[&format!("<< /Type /Catalog {intent} >>"), "null"], ""); + let mut profile = vec![0; size]; + profile[16..20].copy_from_slice(b"CMYK"); + doc.set(pdfcraft_cos::ObjRef::new(2, 0), Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &profile))); + assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); + } +} From dfde90787f416f8d4953f861f308a3407f075839 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:14:55 +0000 Subject: [PATCH 040/142] fix(render): list a repeated annotation once and cap what the listing keeps Inspection built an annotation record, with owned copies of its text, for every /Annots entry on every page, so one comment referenced many times, a page-shared /Annots array, or many small comments sharing one long /Contents string multiplied that text in memory. Pages are read in order, an annotation or /Annots array already listed is skipped, and the listing stops once the kept text and geometry pass 64 MiB. --- crates/render/src/inspect.rs | 75 ++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index e9afb4ec4..6c3bd2c0d 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -38,6 +38,24 @@ const MAX_XFA_CONFIG_BYTES: usize = 16 << 20; const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096; const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024; +/// Text and geometry `DocInfo::annotations` keeps, as counted by [`retained`]. Past it the rest +/// are not listed: real comments stay far below, many small ones sharing one long string do not. +const MAX_ANNOTATION_BYTES: usize = 64 << 20; + +/// The bytes a listed annotation keeps. +fn retained(a: &Annotation) -> usize { + let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent]; + text.into_iter() + .map(|t| t.as_ref().map_or(0, String::len)) + .chain([ + a.subtype.len(), + std::mem::size_of_val(a.quads.as_slice()), + std::mem::size_of_val(a.points.as_slice()), + std::mem::size_of::(), + ]) + .fold(0, usize::saturating_add) +} + /// At most this many layers are read from all of `/RBGroups` together. const MAX_LAYER_GROUP_ENTRIES: usize = 4096; @@ -1142,10 +1160,26 @@ impl<'a> Inspector<'a> { // ── annotations ───────────────────────────────────────────────────────────────────────── fn annotations(&self, info: &mut DocInfo) { - for (&id, &page) in &self.page_index { + let mut pages: Vec<(ObjectId, usize)> = self.page_index.iter().map(|(&id, &page)| (id, page)).collect(); + pages.sort_unstable_by_key(|&(_, page)| page); + // An annotation, or a whole `/Annots` array, that several entries or pages list is on the + // first of them (§12.5.2 allows one page per annotation). + let mut seen = HashSet::new(); + let mut kept = 0usize; + for (id, page) in pages { let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; + if let Ok(Object::Reference(r)) = page_dict.get(b"Annots") + && !seen.insert(*r) + { + continue; + } let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; for (index, a) in annots.iter().enumerate() { + if let Object::Reference(r) = a + && !seen.insert(*r) + { + continue; + } let Some(d) = self.dict(a) else { continue }; let Some(subtype) = self.name(d, b"Subtype") else { continue }; if subtype == "FileAttachment" @@ -1209,7 +1243,7 @@ impl<'a> Inspector<'a> { } _ => Vec::new(), }; - info.annotations.push(Annotation { + let annotation = Annotation { page, subtype, author: self.text(d, b"T"), @@ -1228,7 +1262,15 @@ impl<'a> Inspector<'a> { intent: self.name(d, b"IT"), fill_sign, points, - }); + }; + kept = kept.saturating_add(retained(&annotation)); + if kept > MAX_ANNOTATION_BYTES { + break; + } + info.annotations.push(annotation); + } + if kept > MAX_ANNOTATION_BYTES { + break; } } info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); @@ -2583,6 +2625,33 @@ trailer << /Root 1 0 R >> } } + /// One comment referenced many times, or a page-shared `/Annots` array, is listed once, and many + /// small comments sharing one large `/Contents` string stop at the retained-bytes budget. + #[test] + fn repeated_annotations_are_listed_once_within_a_byte_budget() { + let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 7 0 R >> ".repeat(1200); + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ + 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ + 5 0 obj [{} {small}] endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (note) >> endobj\n\ + 7 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + "6 0 R ".repeat(1000), + "x".repeat(64 << 10), + ); + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; + Inspector::new(&doc).fill(&mut info); + let notes = &info.annotations; + assert_eq!(notes.iter().filter(|a| a.contents.as_deref() == Some("note")).count(), 1); + assert!(notes.iter().all(|a| a.page == 0), "the shared array is the first page's"); + assert!(notes.len() > 1); + assert!(notes.iter().map(retained).sum::() <= MAX_ANNOTATION_BYTES); + } + /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. From 77ee1c8a26c2efadc82952888ffd69ccba36e6de Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:16:19 +0000 Subject: [PATCH 041/142] fix(sign): read DSS revocation evidence one stream at a time within caps Revocation checking decoded every /CRLs and /OCSPs entry under the general 1 GiB stream limit and kept them all before parsing any, so one compressed stream referenced many times multiplied decoding and memory. Evidence now decodes lazily, each stream once and within the 2 MiB evidence cap, with at most 1024 entries and 64 MiB per array. --- crates/sign/src/pdf.rs | 47 ++++++++++++++++++-------- crates/sign/src/pdf_discovery_tests.rs | 22 ++++++++++++ crates/sign/src/revocation.rs | 2 +- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 82a801dee..04598e033 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -975,6 +975,37 @@ fn finish_validation( } } +/// Evidence streams read from one `/DSS` array. +const MAX_DSS_ENTRIES: usize = 1024; +/// Decoded bytes read from one `/DSS` array; real CRLs and OCSP responses are kilobytes each. +const MAX_DSS_BYTES: usize = 64 << 20; + +/// The decoded streams of the `/DSS` evidence array `key` (`/CRLs`, `/OCSPs`), one at a time so +/// each is checked before the next decodes: each stream once and within the evidence size cap, +/// at most [`MAX_DSS_ENTRIES`] of them and [`MAX_DSS_BYTES`] together. +fn dss_evidence<'a>(doc: &'a Document, dss: &Dict, key: &[u8]) -> impl Iterator> + 'a { + let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let mut items = items.into_iter().take(MAX_DSS_ENTRIES); + let mut seen = HashSet::new(); + let mut left = MAX_DSS_BYTES; + std::iter::from_fn(move || { + for item in items.by_ref() { + if left == 0 { + return None; + } + if item.as_ref().is_some_and(|r| !seen.insert(r)) { + continue; + } + let o = doc.resolve(&item); + let Object::Stream(s) = o.as_ref() else { continue }; + let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE.min(left)) else { continue }; + left = left.saturating_sub(bytes.len()); + return Some(bytes); + } + None + }) +} + /// Check the revocation evidence in the catalog's `/DSS` against the signer's chain at `at`. /// Only evidence whose signature verifies against the issuer counts; returns whether the /// certificate is revoked (which invalidates the signature). @@ -985,25 +1016,13 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio let Some(dss) = doc.get(root).as_dict().and_then(|c| c.get(b"DSS").cloned()).map(|d| doc.resolve(&d)).and_then(|d| d.as_dict().cloned()) else { return false; }; - let pull = |key: &[u8]| -> Vec> { - let mut out = Vec::new(); - for item in dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { - let o = doc.resolve(&item); - if let pdfcraft_cos::Object::Stream(s) = &*o - && let Ok(bytes) = s.decoded() - { - out.push(bytes); - } - } - out - }; let issuer = info.chain.get(1).unwrap_or(signer); // A verified revocation wins; "good" from any covering evidence is reported, but never // contradicts a revocation. let mut revoked = None; let mut covered = false; let mut sources = Vec::new(); - for r in pull(b"CRLs") { + for r in dss_evidence(doc, &dss, b"CRLs") { let Ok(list) = CertificateList::parse(&r) else { continue }; sources.push("CRL"); match list.check(signer, issuer, at) { @@ -1016,7 +1035,7 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio } } if revoked.is_none() { - for r in pull(b"OCSPs") { + for r in dss_evidence(doc, &dss, b"OCSPs") { let Ok(resp) = OcspResponse::parse(&r) else { continue }; sources.push("OCSP"); match resp.check(signer, issuer, at) { diff --git a/crates/sign/src/pdf_discovery_tests.rs b/crates/sign/src/pdf_discovery_tests.rs index 66bf7c8a0..9bc444823 100644 --- a/crates/sign/src/pdf_discovery_tests.rs +++ b/crates/sign/src/pdf_discovery_tests.rs @@ -371,3 +371,25 @@ fn only_grew_compares_huge_arrays_without_quadratic_work() { assert!(!only_grew(&doc, &doc, &ints(20_000), &ints(20_001), 0)); assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); } + +#[test] +fn dss_evidence_decodes_each_stream_once_within_its_caps() { + // One small stream listed 1000 times, then streams that inflate past the evidence cap. + let mut doc = fixture(&[]); + let small = doc.add(Object::Stream(Stream::flate(Dict::new(), b"evidence"))); + let mut items = vec![Object::Ref(small); 1000]; + for _ in 0..4 { + items.push(Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![0; crate::revocation::MAX_EVIDENCE + 1]))))); + } + let mut dss = Dict::new(); + dss.set(b"CRLs".to_vec(), Object::Array(items)); + let read: Vec> = dss_evidence(&doc, &dss, b"CRLs").collect(); + assert_eq!(read, vec![b"evidence".to_vec()]); + // Distinct streams stop at the entry and byte budgets. + let items: Vec = + (0..MAX_DSS_ENTRIES + 10).map(|_| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &[1; 1 << 16]))))).collect(); + dss.set(b"OCSPs".to_vec(), Object::Array(items)); + let total: usize = dss_evidence(&doc, &dss, b"OCSPs").map(|b| b.len()).sum(); + assert!(total <= MAX_DSS_BYTES, "{total}"); + assert!(dss_evidence(&doc, &dss, b"OCSPs").count() <= MAX_DSS_ENTRIES); +} diff --git a/crates/sign/src/revocation.rs b/crates/sign/src/revocation.rs index b4a9d820f..057e0717d 100644 --- a/crates/sign/src/revocation.rs +++ b/crates/sign/src/revocation.rs @@ -8,7 +8,7 @@ use crate::x509::Certificate; use crate::{SignError, Time}; /// A DER size cap: real CRLs and OCSP responses are far smaller. -const MAX_EVIDENCE: usize = 2 * 1024 * 1024; +pub(crate) const MAX_EVIDENCE: usize = 2 * 1024 * 1024; const ID_PKIX_OCSP_BASIC: &str = "1.3.6.1.5.5.7.48.1.1"; const EKU_OCSP_SIGNING: &str = "1.3.6.1.5.5.7.3.9"; From 2e1c029fa82efeaae0b9c339a6b1ad3ea6d90f08 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:18:26 +0000 Subject: [PATCH 042/142] fix(xfa): every flowed subform instance spends the layout budget Nested repeating subforms multiplied their instances (up to 50 per level over 64 levels), and an empty instance neither measured nor placed anything, so it spent none of the measurement or item budgets. Each instance now counts as a measurement, and layout stops with TooLarge once the budget is spent. --- crates/xfa/src/layout.rs | 3 +++ crates/xfa/src/tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 326e7f741..b4d331255 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -769,6 +769,9 @@ impl Layouter<'_> { self.warn_truncated(node, &ctx.som, instances); let ctx_parent = ctx; for i in 0..instances { + // Every instance spends budget, so nested repeats that place nothing still stop. + self.measures.set(self.measures.get().saturating_add(1)); + self.check_measures()?; let ctx = ctx_parent.with_som(&sf.common, sib + i); let (rect, _, _) = self.cursor(); let (x, w) = avail.unwrap_or((rect.x, rect.w)); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 84c9fc00f..c5de7ad88 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -404,6 +404,18 @@ fn repeated_pictures_are_bounded_in_total_bytes() { assert!(layout_xml(&ok).is_ok(), "one small picture must lay out"); } +#[test] +fn empty_repeating_subforms_spend_the_layout_budget() { + // Four nested levels of 50 empty instances are 6.25 million instances that place nothing + // and measure nothing; each one counts against the layout budget. + let level = ""; + let tpl = format!("", level.repeat(4), "".repeat(4)); + assert!(matches!(layout_xml(&tpl), Err(XfaError::TooLarge(_)))); + // Three levels (125,000 instances) still lay out. + let tpl = format!("", level.repeat(3), "".repeat(3)); + assert!(layout_xml(&tpl).is_ok()); +} + #[test] fn the_packets_can_be_one_stream_or_named_parts() { // Named parts, with the template split from the rest. From 306627ee778ca04498dc4a14f467bf6def134ddc Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:19:08 +0000 Subject: [PATCH 043/142] fix(xfa): read a packet stream the XFA array lists again only once The packet reader decoded the stream of every /XFA array entry and charged only the decoded output to its 64 MB budget, so one stream that decodes to almost nothing (Flate over hex whitespace), listed many times, repeated a large decode for free. A stream reference already read is skipped. --- crates/xfa/src/packets.rs | 6 ++++++ crates/xfa/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/crates/xfa/src/packets.rs b/crates/xfa/src/packets.rs index 875cdc894..f79b071c0 100644 --- a/crates/xfa/src/packets.rs +++ b/crates/xfa/src/packets.rs @@ -113,7 +113,13 @@ pub fn read_packets(doc: &Document) -> Result, XfaError> { let needs_rendering = bool_of(catalog.as_dict().and_then(|c| c.get(b"NeedsRendering"))).or_else(|| bool_of(acro.get(b"NeedsRendering"))); let has_fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().map(|a| !a.is_empty())).unwrap_or(false); let mut total = 0usize; + // A stream listed again adds nothing but another decode, which may cost far more than the + // bytes it yields (a Flate stream of hex whitespace). + let mut seen = std::collections::HashSet::new(); let mut read = |o: &Object| -> Result, XfaError> { + if o.as_ref().is_some_and(|r| !seen.insert(r)) { + return Ok(None); + } let s = doc.resolve(o); let Object::Stream(s) = &*s else { return Ok(None) }; let bytes = s.decoded_within(MAX_XDP)?; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index c5de7ad88..03a7d2c5a 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -416,6 +416,23 @@ fn empty_repeating_subforms_spend_the_layout_budget() { assert!(layout_xml(&tpl).is_ok()); } +#[test] +fn a_packet_stream_listed_many_times_is_read_once() { + let mut doc = Document::open(Arc::new(shell(""))).unwrap(); + let packet = pdfcraft_cos::ObjRef::new(5, 0); + let items: Vec = + (0..1000).flat_map(|_| [Object::String(pdfcraft_cos::PdfString::literal(b"template".to_vec())), Object::Ref(packet)]).collect(); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + let mut acro = c.get(b"AcroForm").and_then(Object::as_dict).cloned().unwrap(); + acro.set(b"XFA".to_vec(), Object::Array(items)); + c.set(b"AcroForm".to_vec(), Object::Dict(acro)); + }) + .unwrap(); + let p = read_packets(&doc).unwrap().unwrap(); + assert_eq!(p.xdp.matches(" Date: Thu, 22 Oct 2026 17:12:57 +0000 Subject: [PATCH 044/142] fix(fonts): let auto-deref pass the candidate objects to the program match Clippy's explicit_auto_deref rejects the explicit derefs. --- crates/fonts/src/embedding.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index 23f34dbb3..2b7b4e17e 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -290,7 +290,7 @@ impl<'a> ProgramMatch<'a> { /// Whether the resource's program is this one. fn written(&mut self, doc: &Document, w: &Written) -> bool { - w.program.as_ref().is_some_and(|r| self.is(doc, r, &*doc.get(r))) + w.program.as_ref().is_some_and(|r| self.is(doc, r, &doc.get(r))) } } @@ -300,7 +300,7 @@ fn existing_program(doc: &Document, fonts: &Dict, programs: &mut ProgramMatch) - written(doc, fonts) .find(|w| programs.written(doc, w)) .map(|w| w.program) - .or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &*o).then_some(Object::Ref(r)))) + .or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &o).then_some(Object::Ref(r)))) } /// A `PCFull` resource that [`embed_mapping`] wrote. From a525cf1d424de0cdf1dc50e34f1b6cfedcdda24e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:12:51 +0000 Subject: [PATCH 045/142] fix(js): refuse code compiled from strings that skipped the script checks eval and the Function constructors compiled code built at run time, which never passed the size and nesting checks a script passes before it runs, so a short form script could hand boa's recursive parser a deeply nested string and overflow the stack. Form scripts now refuse to compile code from strings. XFA scripts, which the runner itself starts through eval, hold such code to the same checks as the script. --- crates/js/src/lib.rs | 42 +++++++++++++++++++++++++++++++++++++++++- crates/js/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ crates/js/src/xfa.rs | 5 ++++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 785345c67..ae94feb0a 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -27,9 +27,11 @@ use std::cell::RefCell; use std::collections::BTreeSet; use std::sync::Arc; +use boa_engine::context::HostHooks; use boa_engine::object::builtins::{JsArray, JsFunction}; use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer}; use boa_engine::property::Attribute; +use boa_engine::realm::Realm; use boa_engine::{Context, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; /// The kinds of field, as `field.type` names them. @@ -1128,8 +1130,46 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc JsResult<()> { + Err(JsNativeError::typ().with_message("scripts can't compile code from strings (eval, Function)").into()) + } +} + +/// Host hooks that hold code compiled from strings to the checks a script passes ([`refuse`]), +/// for runners that start the script itself through `eval`. +pub(crate) struct CheckedCodeFromStrings; + +impl HostHooks for CheckedCodeFromStrings { + fn ensure_can_compile_strings(&self, _: Realm, parameters: &[JsString], body: &JsString, _: bool, _: &mut Context) -> JsResult<()> { + let units = parameters.iter().chain(std::iter::once(body)).fold(0usize, |n, s| n.saturating_add(s.len())); + if units > MAX_SCRIPT_BYTES { + return Err(JsNativeError::range().with_message("the code is too long to compile").into()); + } + // As one text: brackets left open in one parameter carry into the next. + let mut source = parameters.iter().map(JsString::to_std_string_lossy).collect::>().join(","); + source.push('\n'); + source.push_str(&body.to_std_string_lossy()); + match refuse(&source) { + Some(why) => Err(JsNativeError::range().with_message(why).into()), + None => Ok(()), + } + } +} + fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { - let mut ctx = Context::default(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => { + let error = Some(format!("the script engine could not start: {e}")); + return Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error, ..Default::default() }; + } + }; ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); ctx.insert_data(RefCell::new(Host { diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 7a9efb5df..5b6adef53 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -313,6 +313,23 @@ fn ordinary_nesting_still_runs() { assert_eq!(go(&s, &Event::field("Calculate", "a", "")).value, "1000"); } +/// Code built from strings at run time would reach the parser without the size and nesting +/// checks the script itself passed, so `eval` and the `Function` constructors refuse it. +#[test] +fn scripts_cannot_compile_code_from_strings() { + for s in [ + "event.value = eval('1 + 1');", + "event.value = (0, eval)('1 + 1');", + "event.value = new Function('return 2')();", + "event.value = Function('a', 'return a')(2);", + "event.value = (function* () {}).constructor('yield 2')().next().value;", + "event.value = new Array(1001).join('(') + '1' + new Array(1001).join(')'); event.value = eval(event.value);", + ] { + let o = go(s, &Event::field("Calculate", "a", "")); + assert!(o.error.as_deref().is_some_and(|e| e.contains("compile code from strings")), "{s}: {o:?}"); + } +} + mod xfa_model { use crate::Limits; use crate::xfa::*; @@ -398,6 +415,22 @@ mod xfa_model { assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]); } + /// The script runs through `eval`, so code built from strings at run time is held to the + /// script's own size and nesting checks instead of being refused outright. + #[test] + fn code_built_from_strings_is_checked_like_the_script() { + let total = "form1[0].page1[0].total[0]"; + assert_eq!(go("eval('qty.rawValue * 2')", total, "calculate").result.as_deref(), Some("8")); + assert_eq!(go("new Function('return 3')()", total, "calculate").result.as_deref(), Some("3")); + for s in [ + "eval(new Array(1001).join('(') + '1' + new Array(1001).join(')'))", + "new Function(new Array(1001).join('[') + new Array(1001).join(']'))", + ] { + let o = go(s, total, "calculate"); + assert!(o.error.as_deref().is_some_and(|e| e.contains("nested too deeply")), "{s}: {o:?}"); + } + } + /// A form whose one field `d` holds the ISO date 2026-10-20, shown as 20261020. pub(super) fn dated_form() -> XfaNode { let d = XfaNode { formatted: Some("20261020".into()), ..field("d", "form1[0].page1[0].d[0]", "2026-10-20", false) }; diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 5a693db9f..490408a10 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -1473,7 +1473,10 @@ pub(crate) fn warm_up() { /// `started` is called once the engine is ready: a time limit on the script runs from then. pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { warm_up(); - let mut ctx = Context::default(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => return XfaOutcome { error: Some(format!("the script engine could not start: {e}")), ..Default::default() }, + }; ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); let host_state = XHost::new(root, &event.target, doc); From 5dc64face0131a62712d8f414352787e4ef92617 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:23:04 +0000 Subject: [PATCH 046/142] fix(automation): keep page text from tools when copying is not allowed text_lines returned a copy-restricted document's lines, ocr_recognize returned the words it recognised, and text_edit on a paragraph echoed the paragraph's own text, all without checking the copying permission that the other text tools check. text_lines is refused like them; recognising and restyling still work with permission to change the document, and leave the text out. --- crates/automation/src/a11y.rs | 8 +++-- crates/automation/src/lib.rs | 13 ++++++-- crates/automation/src/tools.rs | 4 +-- crates/automation/tests/it/automation.rs | 38 ++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 212454973..21bde353e 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -626,13 +626,17 @@ impl Automation { pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { let settings = self.ocr_settings(a)?; let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let id = self.doc(a)?.id; + let doc = self.doc(a)?; + // Recognizing needs only permission to change the document; the words it found are + // returned only when copying is allowed too. + let (id, copy) = (doc.id, doc.allows_copying()); let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?; let list: Vec = found .iter() .map(|p| match &p.skipped { Some(why) => json!({ "page": p.page + 1, "skipped": why }), - None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), + None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), + None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }), }) .collect(); Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index c90240875..522705bb1 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -435,7 +435,7 @@ impl Automation { "doc_revisions" => self.doc_revisions(&a)?, "text_lines" => { let page = self.page(&a)?; - let doc = self.doc(&a)?; + let doc = self.doc_to_copy(&a)?; let info = &doc.info.pages[page]; let r = |x: f32| (x as f64 * 100.0).round() / 100.0; let lines: Vec = doc @@ -709,7 +709,11 @@ impl Automation { let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy]; let wanted: String = text.split_whitespace().collect(); let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?; - let after = self.doc(&a)?.text_blocks(page); + let doc = self.doc(&a)?; + let after = doc.text_blocks(page); + // Without `text` the paragraph keeps the document's own words: not echoed when + // the document doesn't allow copying them. + let copy = doc.allows_copying(); // Only a paragraph that now shows exactly the requested text is the edited one. A // neighbour that overlaps the old box (overlaid text, a text layer) must not take // its number, and neither must a paragraph the edit merged it into: rewriting that @@ -717,7 +721,10 @@ impl Automation { let edited = block_overlapping(&after, moved) .filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::() == wanted)); if let Some(b) = edited.and_then(|i| after.get(i)) { - out["paragraph"] = json!({ "text": b.text, "lines": b.lines.len(), "font": b.base_font }); + out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font }); + if copy { + out["paragraph"]["text"] = json!(b.text); + } } let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after); if !stale.is_empty() { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 9ce6b12d4..4fff59c07 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1172,7 +1172,7 @@ pub fn tools() -> Vec { )), t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.") .with(schema(json!({ "enabled": { "type": "boolean" } }), &[])), - t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") + t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text (left out when the document's security doesn't allow copying), word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") .cmd("ocr.recognize") .with(schema( json!({ @@ -1332,7 +1332,7 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])), - t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.") + t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used, and for a paragraph its text unless the document's security doesn't allow copying. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 0ebf3a9bc..778deaaec 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2130,6 +2130,44 @@ fn protecting_through_tools() { ok(&mut c, "doc_open", json!({ "path": "open.pdf" })); } +/// A document that allows changes but not copying: the tools that return a page's text refuse, +/// or leave the text out where the tool also changes the page (a restyled paragraph, recognized +/// words). +#[test] +fn copy_restricted_text_stays_in_the_document() { + let dir = workdir("copy-restricted-text"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let locked = json!({ "open_password": "open", "permissions_password": "boss", "changes": "any-except-extract" }); + let mut protect = locked.clone(); + protect["doc"] = json!(doc); + ok(&mut a, "doc_protect", protect); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" })); + let re = ok(&mut a, "doc_open", json!({ "path": "locked.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + match a.call("text_lines", &json!({ "doc": re, "page": 1 })) { + Err(ToolError::Failed(m)) => assert!(m.contains("don't allow copying"), "{m}"), + other => panic!("text_lines is refused: {other:?}"), + } + let restyled = ok(&mut a, "text_edit", json!({ "doc": re, "page": 1, "paragraph": 1, "size": 30 })); + assert!(!restyled.to_string().contains("Page"), "{restyled}"); + if ok(&mut a, "ocr_status", json!({}))["available"] != true { + eprintln!("skipped the OCR part: OCR models not installed"); + return; + } + let text = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Recognised words stay private" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({ "doc": text, "folder": "scan", "dpi": 150, "pages": [1] })); + let png = std::fs::read_dir(dir.join("scan")).unwrap().next().unwrap().unwrap().path(); + let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap(); + let mut protect = locked; + protect["doc"] = json!(scan); + ok(&mut a, "doc_protect", protect); + ok(&mut a, "doc_save", json!({ "doc": scan, "path": "scan.pdf" })); + let scan = ok(&mut a, "doc_open", json!({ "path": "scan.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "ocr_recognize", json!({ "doc": scan })); + assert!(r["words"].as_u64().unwrap() >= 3, "{r}"); + assert!(r["pages"][0].get("text").is_none() && !r.to_string().to_lowercase().contains("private"), "{r}"); +} + /// A form secured with "fill-sign" changes and no open password (how secured forms are usually /// distributed): its existing signature field can be signed without the permissions password, /// the update keeps the encryption, and the signature validates after reopening. A new field From 30300ae71be58cc5e1800cdbd19c9ec432f7a0ee Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:46 +0000 Subject: [PATCH 047/142] fix(engine): compare only documents that allow copying their text Compare read both documents' words out with no check of the copying permission, and its result, report and difference comments carry that text, so a copy-restricted document's text could be taken out through a comparison. Both documents must now allow copying. --- crates/engine/src/compare.rs | 5 ++++- crates/engine/src/tests.rs | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 50508e20e..c23db91f1 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -46,10 +46,13 @@ impl crate::Document { } impl Session { - /// Compare files: the text differences from document `old` to document `new`. + /// Compare files: the text differences from document `old` to document `new`. The result + /// carries both documents' text, so both must allow copying it. pub fn compare(&self, old: DocId, new: DocId) -> Result { let a = self.get(old).ok_or(EditError::NoDocument)?; let b = self.get(new).ok_or(EditError::NoDocument)?; + a.check_copying()?; + b.check_copying()?; Ok(pdfcraft_compare::compare(&a.words(), &b.words())) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0df176226..f93e76526 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1311,6 +1311,12 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() { let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); assert!(s.get(oid).unwrap().allows_copying()); assert!(extracting.iter().all(|c| enabled(&s, oid, c))); + // Comparing reads both documents' text out, into the result, the report and the comments. + let refused = || Err(EditError::NotPermitted("copying text and images")); + assert_eq!((s.compare(id, oid).map(|_| ()), s.compare(oid, id).map(|_| ())), (refused(), refused())); + assert_eq!(s.compare_report(oid, id).map(|_| ()), refused()); + assert!(s.compare(oid, oid).is_ok()); + assert_eq!(s.mark_differences(id, oid).map(|_| ()), refused()); // Unencrypted documents allow everything. let (s, plain) = session_with(1); assert!(s.get(plain).unwrap().check_copying().is_ok() && extracting.iter().all(|c| enabled(&s, plain, c))); From 28ab616eb62a7ddc13b32895cd2215146f7ff812 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:55 +0000 Subject: [PATCH 048/142] fix(engine): print as low-resolution pictures when only that is allowed Printing checked the print permission but not the separate high-quality one (Table 22, bit 12), so a document limited to low-resolution printing came out as its original vector pages, text included. Such a document now always prints as pictures of at most 150 dpi, whatever the settings ask for. --- crates/engine/src/lib.rs | 12 +++++++++++- crates/engine/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e20cbc596..fc1a8d207 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -232,6 +232,10 @@ pub struct DocId(pub u64); /// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. const MAX_UNDO: usize = 100; +/// The highest resolution a document that allows only low-resolution printing (Table 22, bit 12 +/// clear) is printed at. +const LOW_RESOLUTION_PRINT_DPI: f64 = 150.0; + /// The passwords that go with one state of a document (protection can change them). #[derive(Clone, Debug, Default, PartialEq)] struct Keys { @@ -4522,9 +4526,15 @@ impl Session { if !doc.allows_printing() { return Err(EditError::NotPermitted("printing")); } + // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages + // always go as pictures, at no more than the low resolution. + let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { + dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), + grayscale: settings.raster.is_some_and(|r| r.grayscale), + }); // Print as image: the pages used become pictures first, then the picture document is // imposed in their place (its page k is the k-th distinct source page used). - if let Some(raster) = &settings.raster { + if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { let mut distinct: Vec = Vec::new(); for &p in &settings.pages { if !distinct.contains(&p) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f93e76526..2d6730544 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1340,6 +1340,38 @@ fn print_restricted_documents_disable_print_unless_opened_by_the_owner() { assert!(enabled(&s, low, "print.dialog"), "low-resolution printing is printing"); } +/// Table 22, bit 12 clear: printing is limited to a low-level representation. Whatever the +/// settings ask for, the print-ready PDF carries each page as a picture of at most 150 dpi, with +/// no text or vector artwork left; the owner password lifts the limit. +#[test] +fn low_resolution_printing_prints_pictures_of_at_most_150_dpi() { + let bytes = protected("", "owner", -1 ^ (1 << 11)); + let mut s = Session::new(); + let low = s.open("low.pdf", None, bytes.clone(), None).unwrap(); + let width = f64::from(s.get(low).unwrap().info.pages[0].width); + let actual = print::Settings { pages: vec![0], layout: print::Layout::Size(print::SizeMode::Actual), ..Default::default() }; + for raster in [None, Some(print::Raster { dpi: 600.0, grayscale: false })] { + let printed = s.print_pdf(low, &print::Settings { raster, ..actual.clone() }).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("print.pdf", None, Arc::new(printed.clone()), None).unwrap(); + assert!(page_texts(&s2, id2).iter().all(|t| t.trim().is_empty()), "{raster:?}: no text is left"); + let doc = pdfcraft_cos::Document::open(Arc::new(printed)).unwrap(); + let widths: Vec = doc + .scan_objects() + .filter_map(|(_, o)| match &*o { + pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image") => st.dict.int(b"Width"), + _ => None, + }) + .collect(); + assert!(!widths.is_empty() && widths.iter().all(|&w| w as f64 <= width * 150.0 / 72.0 + 1.0), "{raster:?}: {widths:?} px for {width} pt"); + } + let owner = s.open("low.pdf", None, bytes, Some("owner")).unwrap(); + let printed = s.print_pdf(owner, &actual).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("print.pdf", None, Arc::new(printed), None).unwrap(); + assert!(page_texts(&s2, id2).iter().any(|t| !t.trim().is_empty()), "the owner prints the pages themselves"); +} + #[test] fn the_permissions_password_unlocks_an_open_document() { // Only a permissions password (#995, #1077): opens without asking. Changes are allowed, From 73f11b2a29075094381d0a9d4c1bbcdb90627c8e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:55 +0000 Subject: [PATCH 049/142] fix(engine): fail an action whose information-removal step fails A step that applies an edit logged any error and carried on, which is right for a flatten with nothing to flatten but not for removing hidden information or sanitizing: on a signed file the removal was refused, and the unchanged file was written out as the action's successful result. Those steps now fail the action unless there was nothing to remove. --- crates/engine/src/actions.rs | 6 +++++- crates/engine/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/actions.rs b/crates/engine/src/actions.rs index f679cf87c..f7d11d47e 100644 --- a/crates/engine/src/actions.rs +++ b/crates/engine/src/actions.rs @@ -191,9 +191,13 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> return Ok(None); } }; - // Steps that find nothing to do (no comments to flatten, …) aren't failures. + // Steps that find nothing to do (no comments to flatten, …) aren't failures. A removal step + // that didn't remove is: the file would be passed on as cleaned with the data still in it. + let removes = matches!(step, Step::RemoveHiddenInformation | Step::Sanitize); match s.apply(id, edit) { Ok(()) => log.push(step.label().to_string()), + Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) if removes => log.push(format!("{}: nothing to remove", step.label())), + Err(e) if removes => return Err(e), Err(e) => log.push(format!("{}: {e}", step.label())), } Ok(None) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2d6730544..217d8d166 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4274,6 +4274,24 @@ fn actions_run_their_steps_on_files() { assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), |_, _| {}).is_err()); } +/// A removal step that fails (here: a signed file can't be rewritten without its hidden data) +/// fails the action, so the file is not passed on as cleaned. +#[test] +fn actions_fail_when_hidden_information_could_not_be_removed() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(2)), None).unwrap(); + s.apply(id, Edit::SetInfo { key: "Author".into(), value: "Private Name".into() }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, None).unwrap(); + let signed = s.sign(id, &digital_id, SignOptions { page: 1, ..SignOptions::default() }).unwrap(); + let built = actions::builtin(); + let sanitize = built.iter().find(|a| a.name == "Sanitize Documents").unwrap(); + let e = actions::run_on(sanitize, "signed.pdf", signed, |_, _| {}).map(|r| r.log).unwrap_err(); + assert!(e.starts_with("Sanitize document:"), "{e}"); +} + #[test] fn exporting_office_files_keeps_images() { let mut s = Session::new(); From c3bc881607b71be14cdccbba50fc5e81eb54247d Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:39:40 +0000 Subject: [PATCH 050/142] fix(platform): a saved file keeps its group along with its mode The atomic writer gave the new file the replaced file's mode but left it in the saver's own group, so a file readable by one group became readable by another. The staging file joins the replaced file's group before it gets the mode and before anything is written; where it can't, group and others get no access. --- crates/platform/src/staging.rs | 69 +++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index 0b727449c..f6a98c4fe 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -31,16 +31,16 @@ pub fn create_staging( create_staging_as(dir, name, form, suffixes, None) } -/// [`create_staging`], giving the new file the permissions `keep` on Unix. It is created readable -/// and writable by its owner only and then given `keep`, so its mode is never more open than that -/// of the file it will replace, not even while it is still empty (a handle opened then would keep -/// reading what is written later). Only the mode is copied: the owner and group are the saver's. +/// [`create_staging`], giving the new file the mode and group of the file `keep` describes on +/// Unix. It is created readable and writable by its owner only and then given them, so its mode +/// is never more open than that of the file it will replace, not even while it is still empty (a +/// handle opened then would keep reading what is written later). The owner is the saver's. fn create_staging_as( dir: &Path, name: &str, form: StagingName, suffixes: impl IntoIterator, - keep: Option<&std::fs::Permissions>, + keep: Option<&std::fs::Metadata>, ) -> std::io::Result<(PathBuf, std::fs::File)> { // At most 128 bytes of the target's name, cut between characters, so the staging name fits // the 255-byte (Linux, macOS) and 255-unit (Windows) limits however long that name is. @@ -68,7 +68,7 @@ fn create_staging_as( if let Some(keep) = keep { // A volume that can't change modes (FAT, some network shares) leaves the file // owner-only: stricter than the file it replaces, never looser. - let _ = file.set_permissions(keep.clone()); + let _ = file.set_permissions(kept_permissions(&file, keep)); } return Ok((tmp, file)); } @@ -81,6 +81,26 @@ fn create_staging_as( Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every temporary file name tried is taken")) } +/// The permissions a staging file gets to replace the file `keep` describes. The mode's group bits +/// are meant for that file's group, so the staging file joins it first. Where it can't (the saver +/// isn't a member), group and others get nothing: someone the group bits shut out would +/// otherwise read the file as one of the others. +#[cfg(unix)] +fn kept_permissions(file: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::Permissions { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let mut mode = keep.permissions(); + let joined = file.metadata().is_ok_and(|m| m.gid() == keep.gid()) || std::os::unix::fs::fchown(file, None, Some(keep.gid())).is_ok(); + if !joined { + mode.set_mode(mode.mode() & !0o077); + } + mode +} + +#[cfg(not(unix))] +fn kept_permissions(_: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::Permissions { + keep.permissions() +} + /// Unpredictable staging-name suffixes, so a name can't be planted in advance. Each is the /// standard library's keyed hash of a counter (`RandomState::hash_one`, currently SipHash-1-3), /// keyed from the operating system's random source. That is all a staging name needs, but it is @@ -98,7 +118,7 @@ pub fn staging_suffixes() -> impl Iterator { /// and the target is untouched until the new file is complete. The target's folder must exist; /// a folder at the target is refused by the rename. Flushing is the staging file's own /// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file -/// that is replaced keeps its permissions, so a private file stays private; a new file gets the +/// that is replaced keeps its permissions and group, so a private file stays private; a new file gets the /// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where /// the volume stores one. /// On macOS, accessible extended attributes of an existing regular destination are copied @@ -126,9 +146,9 @@ pub fn write_atomically_with( } let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("save"); - // A file that is replaced keeps its permissions (#1790). Unix modes only: Windows is as before. + // A file that is replaced keeps its permissions (#1790) and group. Unix only: Windows is as before. #[cfg(unix)] - let keep = std::fs::metadata(path).ok().map(|m| m.permissions()); + let keep = std::fs::metadata(path).ok(); #[cfg(not(unix))] let keep = None; // A saved file is the same document: it keeps its creation date (#2658). @@ -583,6 +603,37 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A replaced file keeps its group as well as its mode, so group permissions never reach a + /// group that couldn't read the file before (the saver's own group, by default). + #[cfg(target_os = "linux")] + #[test] + fn a_replaced_file_keeps_its_group() { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let dir = test_dir("group"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + let created = std::fs::metadata(&target).unwrap().gid(); + let status = std::fs::read_to_string("/proc/self/status").unwrap(); + let groups = status.lines().find_map(|l| l.strip_prefix("Groups:")).unwrap_or_default(); + let Some(other) = groups.split_whitespace().filter_map(|g| g.parse::().ok()).find(|&g| g != created) else { + eprintln!("skipped: the test needs a second group"); + return; + }; + std::os::unix::fs::chown(&target, None, Some(other)).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640)).unwrap(); + let mut staged = (0, 0); + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { + let m = f.metadata()?; + staged = (m.gid(), m.permissions().mode() & 0o777); + Ok(()) + }) + .unwrap(); + assert_eq!(staged, (other, 0o640), "the staging file has the target's group before it is flushed"); + let m = std::fs::metadata(&target).unwrap(); + assert_eq!((m.gid(), m.permissions().mode() & 0o777), (other, 0o640)); + let _ = std::fs::remove_dir_all(&dir); + } + /// #2658: a save replaces the file, but the document is the same one: it keeps its creation /// date where the system lets a program set it. #[cfg(any(windows, target_os = "macos"))] From 77480498b25f34028198c32f15afb50516664a73 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:04:07 +0000 Subject: [PATCH 051/142] fix(engine): low-resolution printing prints only the chosen content Pages printed as pictures were drawn with everything on them, so form fields only printed the page content, and the document with stamps printed every comment. The pictures are drawn from a copy holding only what the vector print draws. --- crates/engine/src/lib.rs | 3 +- crates/engine/src/print_raster.rs | 26 +++++++---- crates/engine/src/tests.rs | 77 +++++++++++++++++++++++++++++++ crates/print/src/lib.rs | 19 ++++++++ 4 files changed, 114 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fc1a8d207..817eb79a9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4541,8 +4541,7 @@ impl Session { distinct.push(p); } } - let hide_comments = matches!(settings.content, print::Content::Document | print::Content::FormFieldsOnly); - let bytes = print_raster::rasterize(doc, &distinct, raster, hide_comments).map_err(EditError::Print)?; + let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; let remapped = print::Settings { pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs index 117c8e14c..4ed589586 100644 --- a/crates/engine/src/print_raster.rs +++ b/crates/engine/src/print_raster.rs @@ -1,25 +1,33 @@ //! Print as image (Acrobat's Advanced Print Setup ▸ Print As Image): every page //! sent to the printer as a picture at a chosen resolution, for printers that skip text, //! shading or transparency. The pages are rendered the way the viewer shows them (reading -//! colours off, the print's own colours), made into an image-only document, and that document -//! is imposed on the paper like any other. The print is no longer searchable text. +//! colours off, the print's own colours) with only what the print's content choice keeps, made +//! into an image-only document, and that document is imposed on the paper like any other. The +//! print is no longer searchable text. +use pdfcraft_cos::{SaveOptions, write_incremental}; use pdfcraft_create::ImageResolution; -use pdfcraft_print::Raster; +use pdfcraft_print::{Content, Raster}; use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderRequest}; -use crate::Document; use crate::export::encode_png; +use crate::{Document, guard}; /// `doc`'s pages `pages` (0-based, each once, in the order given) as an image-only PDF, one -/// page per entry, the size of the source page as displayed. Pages too large for the renderer -/// at `raster.dpi` render at the largest resolution it allows. -pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, hide_comments: bool) -> Result, String> { +/// page per entry, the size of the source page as displayed, drawing what `content` prints. +/// Pages too large for the renderer at `raster.dpi` render at the largest resolution it allows. +pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Content) -> Result, String> { let dpi = raster.dpi.clamp(72.0, 600.0); let src = doc.export_source(); let mut config = src.config.clone(); - config.hide_comments = hide_comments; - let mut renderer = PageRenderer::new(src.bytes, config); + // The copy below holds only what prints, so the viewer's own comment hiding plays no part. + config.hide_comments = false; + let printed = guard(|| -> Result, String> { + let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?; + pdfcraft_print::keep_printed(&mut cos, content); + write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string()) + })??; + let mut renderer = PageRenderer::new(std::sync::Arc::new(printed), config); let mut images: Vec<(String, Vec)> = Vec::with_capacity(pages.len()); let mut used: Option = None; for &page in pages { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 217d8d166..3d347e649 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1372,6 +1372,83 @@ fn low_resolution_printing_prints_pictures_of_at_most_150_dpi() { assert!(page_texts(&s2, id2).iter().any(|t| !t.trim().is_empty()), "the owner prints the pages themselves"); } +/// A 100 × 400 pt page with one band per kind of thing that prints, top to bottom: page content +/// (blue), a filled text field (green), a stamp (red) and a square comment (yellow). +fn print_content_pdf() -> Vec { + let content = "0 0 1 rg 0 300 100 100 re f"; + let ap = |colour: &str| { + let ops = format!("{colour} rg 0 0 100 100 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >> stream\n{ops}\nendstream", ops.len()) + }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 400] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /V (Filled) /F 4 /P 3 0 R /Rect [0 200 100 300] /AP << /N 8 0 R >> >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Stamp /F 4 /P 3 0 R /Rect [0 100 100 200] /AP << /N 9 0 R >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Square /F 4 /P 3 0 R /Rect [0 0 100 100] /AP << /N 10 0 R >> >> endobj\n\ + 8 0 obj {} endobj\n\ + 9 0 obj {} endobj\n\ + 10 0 obj {} endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + ap("0 1 0"), + ap("1 0 0"), + ap("1 1 0"), + ) + .into_bytes() +} + +/// Low-resolution printing prints what the chosen content prints when the pages themselves are +/// printed: form fields only leaves out the page content and every comment, the document alone +/// leaves out the comments, and the document with stamps keeps only the stamps. +#[test] +fn low_resolution_printing_prints_only_the_chosen_content() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(print_content_pdf())).unwrap(); + cos.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ (1 << 11), + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); + let mut s = Session::new(); + let low = s.open("low.pdf", None, bytes.clone(), None).unwrap(); + let owner = s.open("low.pdf", None, bytes, Some("owner")).unwrap(); + // Each band's centre, in pixels from the top of the sheet at 72 dpi, and its colour. + let bands = [("content", 50, [0, 0, 255]), ("field", 150, [0, 255, 0]), ("stamp", 250, [255, 0, 0]), ("comment", 350, [255, 255, 0])]; + let all: &[&str] = &["content", "field", "stamp", "comment"]; + for (content, shown) in [ + (print::Content::DocumentAndMarkups, all), + (print::Content::Document, &["content", "field"][..]), + (print::Content::DocumentAndStamps, &["content", "field", "stamp"][..]), + (print::Content::FormFieldsOnly, &["field"][..]), + ] { + let settings = print::Settings { + pages: vec![0], + paper: (100.0, 400.0), + layout: print::Layout::Size(print::SizeMode::Actual), + content, + ..Default::default() + }; + for (id, how) in [(owner, "pages"), (low, "pictures")] { + let (w, h, rgba) = printed_pixels(s.print_pdf(id, &settings).unwrap()); + assert_eq!((w, h), (100, 400), "{content:?} as {how}"); + for (band, y, colour) in bands { + let px = rgba.get((y * w as usize + 50) * 4..).and_then(|p| p.get(..3)).unwrap(); + let near = |c: [u8; 3]| px.iter().zip(c).all(|(&a, b)| a.abs_diff(b) < 40); + let expected = shown.contains(&band); + assert!(if expected { near(colour) } else { near([255; 3]) }, "{content:?} as {how}: {band} drawn {expected} expected, pixel {px:?}"); + } + } + } +} + #[test] fn the_permissions_password_unlocks_an_open_document() { // Only a permissions password (#995, #1077): opens without asking. Changes are allowed, diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index e935cb938..6839e5f6b 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -696,6 +696,25 @@ fn decoded_contents(doc: &Document, page: &Dict) -> Vec { out } +/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that +/// don't print are dropped, and with [`Content::FormFieldsOnly`] the page content too. For +/// printing pages as pictures, where a renderer draws each page as it stands. +pub fn keep_printed(doc: &mut Document, content: Content) { + for page in pdfcraft_model::pages(doc) { + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let kept: Vec = annots.into_iter().filter(|a| doc.resolve(a).as_dict().is_some_and(|d| prints(doc, d, content))).collect(); + // Fails only when the page object isn't a dictionary, and such a page draws nothing. + let _ = doc.update_dict(page.obj, |d| { + if content == Content::FormFieldsOnly { + d.remove(b"Contents"); + } + if d.contains(b"Annots") { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + }); + } +} + /// Does annotation `d` print under `content`? fn prints(doc: &Document, d: &Dict, content: Content) -> bool { const HIDDEN: i64 = 2; From 2e9b247f3fd16ae51c717781c15cc4698af5ca1d Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:16:15 +0000 Subject: [PATCH 052/142] fix(js): read printd literals in linear time util.printd measured the run of identical characters at every position of the format, literals included, but copied a literal one character at a time. A format with a long stretch of one literal character rescanned the rest of that stretch at every step: quadratic work from one script-built string. Only the date letters are read as runs now. --- crates/js/src/lib.rs | 10 +++++++++- crates/js/src/tests.rs | 11 +++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index ae94feb0a..156eb9fbe 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -752,7 +752,11 @@ fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult let mut i = 0; while i < b.len() { let c = b[i]; - let run = b[i..].iter().take_while(|x| **x == c).count(); + // Only date letters read as runs; a literal is copied one character at a time, so + // measuring its run would rescan a long literal stretch at every position. + let run = if b"ymdHhMst".contains(&c) { b[i..].iter().take_while(|x| **x == c).count() } else { 1 }; + #[cfg(test)] + PRINTD_SCANNED.fetch_add(run as u64, std::sync::atomic::Ordering::Relaxed); let push_num = |out: &mut String, v: i64, n: usize| { if n >= 2 { out.push_str(&format!("{v:02}")); @@ -1011,6 +1015,10 @@ fn event_object(ctx: &mut Context, e: &Event) -> JsObject { // stack a script can use: brackets and prefix operators by the limits below, and every other // construct (a few KiB per level, several bytes of source each) by the length limit. +/// Bytes `util.printd` has scanned for runs of a format character, for tests that bound its work. +#[cfg(test)] +pub(crate) static PRINTD_SCANNED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + /// Longest script, in bytes. pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; /// Largest width or precision accepted by `util.printf`. diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 5b6adef53..13b51ba47 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -244,6 +244,17 @@ fn document_requests_and_console() { ); } +#[test] +fn printd_reads_a_long_literal_run_in_linear_work() { + crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed); + let o = go("event.value = util.printd(new Array(20001).join('x'), new Date(2024, 0, 5)).length;", &Event::field("Calculate", "total", "")); + assert_eq!(o.error, None); + assert_eq!(o.value, "20000"); + // Other tests format short dates at the same time; they add a few dozen bytes at most. + let scanned = crate::PRINTD_SCANNED.load(std::sync::atomic::Ordering::Relaxed); + assert!(scanned < 40_000, "printd scanned {scanned} bytes for a 20000-byte format"); +} + #[test] fn document_level_functions_are_available() { let o = run( From a5cfc3eec43d6c615bc34852d828deac4a079a82 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:16:27 +0000 Subject: [PATCH 053/142] fix(js): cap a form script's alerts, console lines and requests The AcroForm script host kept every alert, console line and request a script made, each as long as the script made it. A loop printing a long script-built string held a copy per iteration. The host applies the XFA host's limits: message and console counts, the effect count for requests, each message, URL and field name cut to a line, and a byte budget across all of them, with a closing console line saying how many were left out. --- crates/js/src/lib.rs | 104 +++++++++++++++++++++++++++++++++++------ crates/js/src/tests.rs | 29 ++++++++++++ crates/js/src/xfa.rs | 5 +- 3 files changed, 124 insertions(+), 14 deletions(-) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 156eb9fbe..f2221f750 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -203,6 +203,66 @@ struct Host { console: Vec, requests: Vec, doc: DocInfo, + /// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`]. + kept: usize, + /// Alerts, console lines and requests the limits left out. + dropped: usize, +} + +impl Host { + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= xfa::MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped = self.dropped.saturating_add(1); + false + } + } + } + + fn alert(&mut self, m: String) { + if self.alerts.len() >= xfa::MAX_ALERTS { + self.dropped = self.dropped.saturating_add(1); + } else if self.room(m.len()) { + self.alerts.push(m); + } + } + + fn print(&mut self, m: String) { + if self.console.len() >= xfa::MAX_CONSOLE { + self.dropped = self.dropped.saturating_add(1); + } else if self.room(m.len()) { + self.console.push(m); + } + } + + /// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`] + /// requests and the run's byte budget. + fn request(&mut self, r: Request) { + if self.requests.len() >= xfa::MAX_EFFECTS { + self.dropped = self.dropped.saturating_add(1); + return; + } + let r = match r { + Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()), + Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)), + Request::Submit(u) => Request::Submit(xfa::clip(u)), + Request::Focus(n) => Request::Focus(xfa::clip(n)), + other => other, + }; + let bytes = match &r { + Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), + _ => 0, + }; + if self.room(bytes) { + self.requests.push(r); + } + } } type Shared = RefCell; @@ -223,6 +283,19 @@ fn text(v: &JsValue, ctx: &mut Context) -> JsResult { Ok(v.to_string(ctx)?.to_std_string_escaped()) } +/// [`text`] cut to a message's length ([`xfa::clip`]), without first copying all of a longer +/// string. +fn text_line(v: &JsValue, ctx: &mut Context) -> JsResult { + if v.is_undefined() || v.is_null() { + return Ok(String::new()); + } + let js = v.to_string(ctx)?; + // Two UTF-16 units per character at most: the clip still sees more than a line's worth. + let keep = xfa::MAX_LINE.saturating_add(1).saturating_mul(2); + let js = if js.len() > keep { js.get(..keep).unwrap_or_default() } else { js }; + Ok(xfa::clip(js.to_std_string_escaped())) +} + fn arg(args: &[JsValue], i: usize) -> JsValue { args.get(i).cloned().unwrap_or_default() } @@ -440,7 +513,7 @@ fn field_object(ctx: &mut Context, name: &str) -> JsObject { Ok(JsValue::undefined()) } fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - host(c)?.borrow_mut().requests.push(Request::Focus(name.to_std_string_escaped())); + host(c)?.borrow_mut().request(Request::Focus(name.to_std_string_escaped())); Ok(JsValue::undefined()) } @@ -494,12 +567,12 @@ fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsRes fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let names = strings(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().requests.push(Request::Reset(names)); + host(ctx)?.borrow_mut().request(Request::Reset(names)); Ok(JsValue::undefined()) } fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().requests.push(Request::Print); + host(ctx)?.borrow_mut().request(Request::Print); Ok(JsValue::undefined()) } @@ -512,7 +585,7 @@ fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult text(&a, ctx)?, }; - host(ctx)?.borrow_mut().requests.push(Request::Submit(url)); + host(ctx)?.borrow_mut().request(Request::Submit(url)); Ok(JsValue::undefined()) } @@ -534,38 +607,38 @@ fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { match v.as_object() { Some(o) if !o.is_array() && !o.is_callable() => { let m = o.get(JsString::from(key), ctx)?; - text(&m, ctx) + text_line(&m, ctx) } - _ => text(v, ctx), + _ => text_line(v, ctx), } } fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = message(&arg(args, 0), "cMsg", ctx)?; - host(ctx)?.borrow_mut().alerts.push(m); + host(ctx)?.borrow_mut().alert(m); // The OK button. Ok(JsValue::from(1)) } fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().requests.push(Request::Beep); + host(ctx)?.borrow_mut().request(Request::Beep); Ok(JsValue::undefined()) } fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = message(&arg(args, 0), "cURL", ctx)?; - host(ctx)?.borrow_mut().requests.push(Request::LaunchUrl(u)); + host(ctx)?.borrow_mut().request(Request::LaunchUrl(u)); Ok(JsValue::undefined()) } @@ -575,8 +648,8 @@ fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { } fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = text(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().console.push(m); + let m = text_line(&arg(args, 0), ctx)?; + host(ctx)?.borrow_mut().print(m); Ok(JsValue::undefined()) } @@ -1187,6 +1260,8 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc JsResult<()> { @@ -1217,6 +1292,9 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc 0 { + out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped)); + } } out } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 13b51ba47..07ae8ecd0 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -244,6 +244,35 @@ fn document_requests_and_console() { ); } +/// Bytes of text a run handed back in its messages, console lines and requests. +fn kept_bytes(o: &Outcome) -> usize { + let req = |r: &Request| match r { + Request::Reset(names) => names.iter().map(String::len).sum(), + Request::Submit(u) | Request::LaunchUrl(u) | Request::Focus(u) => u.len(), + _ => 0, + }; + o.console.iter().chain(&o.alerts).map(String::len).sum::() + o.requests.iter().map(req).sum::() +} + +#[test] +fn console_alerts_and_requests_are_capped_in_count_and_bytes() { + let o = go( + "var s = 'x'; while (s.length < 8192) s += s; \ + for (var i = 0; i < 1500; i++) { console.println(s); app.alert(s); } \ + var a = []; for (var i = 0; i < 2000; i++) a.push(s); \ + for (var i = 0; i < 5; i++) this.resetForm(a); \ + for (var i = 0; i < 12000; i++) app.launchURL('u');", + &Event::field("Mouse Up", "agree", ""), + ); + assert_eq!(o.error, None); + assert!(o.console.len() <= crate::xfa::MAX_CONSOLE + 1, "{} console lines", o.console.len()); + assert!(o.alerts.len() <= crate::xfa::MAX_ALERTS, "{} alerts", o.alerts.len()); + assert!(o.requests.len() <= crate::xfa::MAX_EFFECTS, "{} requests", o.requests.len()); + assert!(o.console.iter().chain(&o.alerts).all(|m| m.chars().count() <= 4_097)); + assert!(kept_bytes(&o) <= crate::xfa::MAX_PAYLOAD, "{} bytes kept", kept_bytes(&o)); + assert!(o.console.last().is_some_and(|l| l.contains("left out")), "{:?}", o.console.last()); +} + #[test] fn printd_reads_a_long_literal_run_in_linear_work() { crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed); diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 490408a10..e875023d6 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -158,7 +158,10 @@ pub const MAX_ALERTS: usize = 100; /// Most console lines one run may print. pub const MAX_CONSOLE: usize = 1_000; /// Longest message or console line kept, in characters. -const MAX_LINE: usize = 4_096; +pub(crate) const MAX_LINE: usize = 4_096; +/// Most bytes of text one run may hand back or write: messages, console lines, URLs, names +/// and the values it sets. Past it, more are left out and noted. +pub(crate) const MAX_PAYLOAD: usize = 32 << 20; /// Longest value a script may put in a field (characters); longer ones are cut and noted. const MAX_VALUE: usize = 65_536; From 72fd2a1b24bdbeff51421165bbcb1230bf3da48b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:18:45 +0000 Subject: [PATCH 054/142] fix(js): bound the text an XFA script's effects and values hold The XFA script host capped the number of effects but not their size: URLs, focus targets and resetData lists were kept whole, a resetData list kept every repeated name, and values written to fields were each cut but not counted together. A loop repeating one long script-built string kept a copy per effect or field. URLs, focus targets and reset names are cut to a message's length, reset lists name each field once, and every effect, console line and value written charges a byte budget for the run; past it they are left out and noted. FormCalc charges an assignment to many fields once per field copied. --- crates/js/src/formcalc.rs | 8 ++-- crates/js/src/tests.rs | 50 +++++++++++++++++++++++ crates/js/src/xfa.rs | 84 ++++++++++++++++++++++++++++++++++----- 3 files changed, 129 insertions(+), 13 deletions(-) diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index a29397339..7a3de5a70 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -15,7 +15,7 @@ use std::rc::Rc; use crate::Limits; use crate::xfa::{ HNode, XHost, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome, child_named, clip, clip_result, descendants_named, instances, - resolve_som, run_within, + reset_names, resolve_som, run_within, }; /// Deepest nesting the parser follows. @@ -1466,8 +1466,7 @@ impl Interp<'_> { } "resetdata" => { let list = self.arg_str(args, 0)?; - let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); - self.h.push(XfaEffect::ResetData(names)); + self.h.push(XfaEffect::ResetData(reset_names(&list))); Ok(Val::Null) } "print" => { @@ -1646,6 +1645,8 @@ impl Interp<'_> { return Err(format!("{som} has no {other} to assign to")); } for k in kids { + // Every destination copies the value: charge each copy. + self.charge(self.cost(&v))?; self.assign_to(Val::Node(k), "rawValue", v.clone())?; } } @@ -1654,6 +1655,7 @@ impl Interp<'_> { } Val::List(items) => { for it in items { + self.charge(self.cost(&v))?; self.assign_to(it, prop, v.clone())?; } Ok(()) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 07ae8ecd0..8ce338b2e 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -665,6 +665,22 @@ mod xfa_model { assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097)); } + #[test] + fn urls_and_reset_lists_are_clipped_and_deduplicated() { + let o = go( + "var s = 'x'; while (s.length < 65536) s += s; for (var i = 0; i < 600; i++) xfa.host.gotoURL(s + i); \ + var r = 'a,'; while (r.length < 262144) r += r; xfa.host.resetData(r);", + "form1[0].page1[0]", + "initialize", + ); + assert_eq!(o.error, None); + let urls: Vec<&String> = o.effects.iter().filter_map(|e| if let XfaEffect::LaunchUrl(u) = e { Some(u) } else { None }).collect(); + assert_eq!(urls.len(), 600); + assert!(urls.iter().all(|u| u.chars().count() <= 4_097)); + let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum(); + assert_eq!(names, 1); + } + #[test] fn a_slow_engine_start_does_not_count_against_the_script() { let ms = std::time::Duration::from_millis; @@ -716,6 +732,40 @@ mod formcalc { o.result.unwrap_or_default() } + /// Assigning one long value to many fields at once stops at the run's byte budget and is + /// charged per copy, and a reset list names each field once. + #[test] + fn bulk_assignments_and_reset_lists_are_bounded() { + let f = |i: usize| super::xfa_model::field("f", &format!("form1[0].page1[0].f[{i}]"), "", false); + let root = XfaNode { + name: "form1".into(), + som: "form1[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![XfaNode { + name: "page1".into(), + som: "form1[0].page1[0]".into(), + kind: Some(XfaKind::Subform), + children: (0..1300).map(f).collect(), + ..Default::default() + }], + ..Default::default() + }; + let ev = XfaEvent { activity: "initialize".into(), target: "form1[0]".into(), ..Default::default() }; + let run = |script: &str| run_formcalc(script, &ev, &XfaDoc::default(), &root, Limits::default()); + let o = run("var s = \"x\"\nwhile (Len(s) < 65536) do s = Concat(s, s) endwhile\npage1.f = s"); + assert_eq!(o.error, None, "{o:?}"); + let kept: usize = o.effects.iter().map(|e| if let XfaEffect::SetValue { value, .. } = e { value.len() } else { 0 }).sum(); + assert!(kept <= MAX_PAYLOAD, "{kept} bytes of values kept"); + assert!(o.notes.iter().any(|n| n.contains("left out")), "{:?}", o.notes); + // 1300 copies of a 1 MiB value are more work than a script may do. + let o = run("var s = \"x\"\nwhile (Len(s) < 1048576) do s = Concat(s, s) endwhile\npage1.f = s"); + assert_eq!(o.error.as_deref(), Some("the script ran too long"), "{:?}", o.notes); + let o = run("var r = \"a,\"\nwhile (Len(r) < 262144) do r = Concat(r, r) endwhile\n$host.resetData(r)\n$host.resetData(r)"); + assert_eq!(o.error, None, "{o:?}"); + let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum(); + assert_eq!(names, 2); + } + /// FormCalc reads `formattedValue` as the field's picture shows it too, until the script /// sets the value. #[test] diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index e875023d6..ae38658e0 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -8,7 +8,7 @@ //! children, then its siblings, then each ancestor's children. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use boa_engine::object::ObjectInitializer; use boa_engine::object::builtins::{JsArray, JsProxy}; @@ -182,6 +182,36 @@ pub(crate) fn clip_value(v: String) -> (String, bool) { } } +/// The field names of a `resetData` list (comma-separated), each cut to [`MAX_LINE`] and +/// named once, at most as many as a snapshot holds nodes. +pub(crate) fn reset_names(list: &str) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for name in list.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if out.len() >= MAX_NODES { + break; + } + let name = clip(name.to_string()); + if seen.insert(name.clone()) { + out.push(name); + } + } + out +} + +/// Bytes of text `e` holds. +fn payload(e: &XfaEffect) -> usize { + match e { + XfaEffect::SetValue { som, value: s } | XfaEffect::SetPresence { som, presence: s } | XfaEffect::SetAccess { som, access: s } => { + som.len().saturating_add(s.len()) + } + XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), + XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), + XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout => 0, + } +} + /// A script's completion value (what a calculate script puts in its field) cut to /// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was. pub(crate) fn clip_result(out: &mut XfaOutcome) { @@ -235,6 +265,9 @@ pub(crate) struct XHost { clipped_values: usize, dropped_alerts: usize, dropped_console: usize, + /// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`]. + kept: usize, + dropped_bytes: usize, } impl XHost { @@ -259,15 +292,34 @@ impl XHost { clipped_values: 0, dropped_alerts: 0, dropped_console: 0, + kept: 0, + dropped_bytes: 0, + } + } + + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped_bytes = self.dropped_bytes.saturating_add(1); + false + } } } /// Record an effect: values, presence and access set again on the same object replace the - /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes) effects are counted - /// and dropped. + /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes, or [`MAX_PAYLOAD`] + /// bytes of text) effects are counted and dropped. pub(crate) fn push(&mut self, e: XfaEffect) { let e = match e { XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)), + XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)), + XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)), + XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()), XfaEffect::SetValue { som, value } => { let (value, cut) = clip_value(value); if cut { @@ -277,6 +329,9 @@ impl XHost { } other => other, }; + if !self.room(payload(&e)) { + return; + } let key = match &e { XfaEffect::SetValue { som, .. } => Some((0u8, som.clone())), XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), @@ -319,7 +374,7 @@ impl XHost { pub(crate) fn print(&mut self, line: String) { if self.console.len() >= MAX_CONSOLE { self.dropped_console += 1; - } else { + } else if self.room(line.len()) { self.console.push(line); } } @@ -336,6 +391,9 @@ impl XHost { if self.dropped_console > 0 { out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); } + if self.dropped_bytes > 0 { + out.push(format!("the script's changes and output passed {} MiB of text; {} more were left out", MAX_PAYLOAD >> 20, self.dropped_bytes)); + } if self.clipped_values > 0 { out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values)); } @@ -346,19 +404,26 @@ impl XHost { /// holds a value; writing one into a subform would put text where another viewer expects /// a data group). Returns whether it was. pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool { - let Some(n) = self.nodes.get_mut(i) else { return false }; + let Some(n) = self.nodes.get(i) else { return false }; if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { return false; } + let som = n.som.clone(); // Cut here, not only in the effect: later reads in the script see what is saved, and // the snapshot stays bounded however often a long value is written. let (text, cut) = clip_value(text); - n.value = text.clone(); - n.formatted = None; - let som = n.som.clone(); if cut { self.clipped_values += 1; } + // The snapshot's copy counts as well as the effect's: past the budget the write is left + // out of both. + if !self.room(text.len()) { + return true; + } + if let Some(n) = self.nodes.get_mut(i) { + n.value = text.clone(); + n.formatted = None; + } self.push(XfaEffect::SetValue { som, value: text }); true } @@ -1027,8 +1092,7 @@ fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { let list = text(&arg(args, 0), ctx)?; - let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); - effect(ctx, XfaEffect::ResetData(names))?; + effect(ctx, XfaEffect::ResetData(reset_names(&list)))?; Ok(JsValue::undefined()) } From 17f5d80f44cdd0fe58f035adc45ddc8f8a02b95b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:20:22 +0000 Subject: [PATCH 055/142] fix(xfa): break a long word in linear time Text layout broke a word wider than its line by measuring the whole growing piece after every character, and counting its characters again each time. A long unbroken word in a wide box with a small font made that quadratic. The piece's advance is kept as a running sum of character widths instead. --- crates/xfa/src/tests.rs | 13 +++++++++++++ crates/xfa/src/text.rs | 18 ++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 84c9fc00f..17e286d6c 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2657,3 +2657,16 @@ fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() { assert!((left_edge("center", "TITLE ") - (36.0 + (w - ink) / 2.0)).abs() < 1e-6); assert!((left_edge("right", "TITLE ") - (36.0 + w - ink)).abs() < 1e-6); } + +/// A word longer than its line is broken by character in work linear in its length. +#[test] +fn a_long_word_breaks_in_linear_work() { + let face = text::Face::of(&Font { typeface: "Arial".into(), size: 1.0, ..Font::default() }); + let word = "i".repeat(20_000); + text::MEASURED.with(|m| m.set(0)); + let b = text::layout(&text::plain(&word), Rect::new(0.0, 0.0, 1000.0, 0.0), &Para::default(), &face, &|_| String::new()); + let measured = text::MEASURED.with(|m| m.get()); + assert!(b.spans.len() > 2, "{} lines", b.spans.len()); + assert_eq!(b.spans.iter().map(|s| s.text.len()).sum::(), 20_000); + assert!(measured < 200_000, "{measured} bytes measured for a 20000-byte word"); +} diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index 1598a4034..fc09d355e 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -123,6 +123,8 @@ impl Face { /// Advance of `s` at this face's size. pub fn width(&self, s: &str) -> f64 { + #[cfg(test)] + MEASURED.with(|m| m.set(m.get().saturating_add(s.len() as u64))); let designed = match self.family { Family::Courier => s.chars().count() as f64 * 0.6 * self.size, Family::Helvetica => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 1.04 } else { 1.0 }, @@ -132,6 +134,12 @@ impl Face { } } +// Bytes of text measured on this thread, for tests that bound layout work. +#[cfg(test)] +thread_local! { + pub(crate) static MEASURED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Default line height: XFA's font leading for the standard faces. pub fn line_height(size: f64) -> f64 { size * 1.15 @@ -233,11 +241,16 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn lines.push((done, p.h_align)); } if ink > avail - indent(line.first) && line.words.is_empty() { - // A word longer than a line: break it by character. + // A word longer than a line: break it by character. The piece's advance grows by + // each character's (widths add up), so no prefix is measured again. let mut piece = String::new(); + let mut advance = 0.0; + let mut buf = [0u8; 4]; for ch in w.text.chars() { + let ch_width = w.face.width(ch.encode_utf8(&mut buf)); piece.push(ch); - if w.face.width(&piece) > avail - indent(line.first) && piece.chars().count() > 1 { + advance += ch_width; + if advance > avail - indent(line.first) && piece.len() > ch.len_utf8() { piece.pop(); let width = w.face.width(&piece); line.words.push(Word { text: std::mem::take(&mut piece), face: w.face.clone(), width }); @@ -246,6 +259,7 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false }); lines.push((done, p.h_align)); piece.push(ch); + advance = ch_width; } } if !piece.is_empty() { From 3412437191ab4cbd18bd2b519d94fada7f42dfa1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:21:00 +0000 Subject: [PATCH 056/142] fix(xfa): bound the embedded field text one text block expands Rich text resolved every embedded-field reference to a fresh copy of the field's value, so a template repeating one reference to a long value many times laid out a copy per reference. One text block expands at most as many bytes of embedded values as a text of its own may hold; past that the values are cut. --- crates/xfa/src/tests.rs | 14 ++++++++++++++ crates/xfa/src/text.rs | 25 +++++++++++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 17e286d6c..31a368481 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2670,3 +2670,17 @@ fn a_long_word_breaks_in_linear_work() { assert_eq!(b.spans.iter().map(|s| s.text.len()).sum::(), 20_000); assert!(measured < 200_000, "{measured} bytes measured for a 20000-byte word"); } + +/// Rich text that embeds one long field value many times lays out a bounded amount of it. +#[test] +fn embedded_field_values_expand_to_a_bounded_amount_of_text() { + let face = text::Face::of(&Font { typeface: "Arial".into(), size: 10.0, ..Font::default() }); + let value = "word ".repeat(3_300); + let run = Run { embed: Some("f".into()), ..Run::default() }; + let rich = RichText { paragraphs: vec![Paragraph { runs: vec![run; 100], h_align: None }] }; + let b = text::layout(&rich, Rect::new(0.0, 0.0, 500.0, 0.0), &Para::default(), &face, &|_| value.clone()); + let kept: usize = b.spans.iter().map(|s| s.text.len()).sum(); + assert!(kept > 0); + assert!(kept <= text::MAX_EMBEDDED, "{kept} bytes of embedded text laid out"); + assert!(text::natural_width(&rich, &Para::default(), &face, &|_| value.clone()) <= face.width(&value) * 70.0); +} diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index fc09d355e..63de87244 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -177,13 +177,27 @@ fn ink_width(w: &Word) -> f64 { if ink.len() == w.text.len() { w.width } else { w.face.width(ink) } } +/// Most bytes of embedded field values one text block shows, as much as a text of its own +/// holds: a value embedded over and over is expanded only so far. +pub(crate) const MAX_EMBEDDED: usize = 1 << 20; + /// Break the runs of a paragraph into words (spaces stay attached to the word before them). -fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str) -> String) -> Vec { +/// Embedded values take from `embed_left` bytes and are cut when it runs out. +fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str) -> String, embed_left: &mut usize) -> Vec { let mut out = Vec::new(); for run in runs { let face = base.with_run(run); let text: String = match &run.embed { - Some(id) => embed(id), + Some(_) if *embed_left == 0 => String::new(), + Some(id) => { + let mut v = embed(id); + if v.len() > *embed_left { + let end = v.char_indices().map(|(i, _)| i).take_while(|i| *i <= *embed_left).last().unwrap_or(0); + v.truncate(end); + } + *embed_left = embed_left.saturating_sub(v.len()); + v + } None => run .text .chars() @@ -224,8 +238,9 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn let avail = (rect.w - para.margin_left - para.margin_right).max(1.0); let mut lines: Vec<(Line, Option)> = Vec::new(); let mut total = 0.0; + let mut embed_left = MAX_EMBEDDED; for (pi, p) in rich.paragraphs.iter().enumerate() { - let ws = words(&p.runs, base, embed); + let ws = words(&p.runs, base, embed, &mut embed_left); let mut line = Line { words: Vec::new(), width: 0.0, size: base.size, first: true }; let mut first_in_para = true; let indent = |first: bool| if first { para.text_indent.max(0.0) } else { 0.0 }; @@ -336,7 +351,9 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn /// margins and first-line indent): how wide a box without an explicit width grows. pub fn natural_width(rich: &RichText, para: &Para, base: &Face, embed: &dyn Fn(&str) -> String) -> f64 { let indent = para.text_indent.max(0.0); - let widest = rich.paragraphs.iter().map(|p| words(&p.runs, base, embed).iter().map(|w| w.width).sum::()).fold(0.0, f64::max); + let mut embed_left = MAX_EMBEDDED; + let widest = + rich.paragraphs.iter().map(|p| words(&p.runs, base, embed, &mut embed_left).iter().map(|w| w.width).sum::()).fold(0.0, f64::max); (para.margin_left + indent + widest + para.margin_right).max(0.0) } From 1dbfa251000956dc968680ebf7f53a47d7f932f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:22:52 +0000 Subject: [PATCH 057/142] fix(xfa): bound the script text the live form's events hold Every instance of a repeated subform records its own copy of each script, and only the number of events was capped. A long script inside nested repeating subforms was copied once per instance before any script ran. The events hold at most as much script text as the XFA packets may; past that the live form is marked truncated, as it is past the event cap. --- crates/xfa/src/script.rs | 52 +++++++++++++++++++--------------------- crates/xfa/src/tests.rs | 27 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index d39d53e37..cb555dc81 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -15,6 +15,9 @@ pub const OVERRIDES_KEY: &[u8] = b"PCXfaOverrides"; const MAX_DEPTH: usize = 64; const MAX_EVENTS: usize = 20_000; +/// Most bytes of script the events hold together: each instance of a repeated subform holds its +/// own copy, so a long script repeated many times stops here (as much as the XFA packets hold). +pub(crate) const MAX_EVENT_SCRIPT_BYTES: usize = 64 << 20; /// Most objects of the live form built (the scripting engine's snapshot holds as many): /// nested repeating subforms multiply, so the walk stops here and says so. pub const MAX_FORM_NODES: usize = 200_000; @@ -67,8 +70,8 @@ pub struct LiveForm { pub root: FormNode, /// Every scripted event, in document order. pub events: Vec, - /// The form was larger than [`MAX_FORM_NODES`] objects (or [`MAX_EVENTS`] events): what - /// is past that was left out. + /// The form was larger than [`MAX_FORM_NODES`] objects (or [`MAX_EVENTS`] events, or + /// [`MAX_EVENT_SCRIPT_BYTES`] of their scripts): what is past that was left out. pub truncated: bool, } @@ -172,6 +175,8 @@ struct Walker<'a> { data: Option<&'a DataNode>, ov: &'a Overrides, events: Vec, + /// Bytes of script the events hold. + script_bytes: usize, /// Objects built so far. nodes: usize, truncated: bool, @@ -190,43 +195,34 @@ impl Walker<'_> { /// Record the scripts of the object at `som` as events: each non-empty event script, then its /// calculate and validate scripts (the validate one with `message`). Once [`MAX_EVENTS`] events - /// are recorded, nothing more is and the form is marked truncated. + /// or [`MAX_EVENT_SCRIPT_BYTES`] of their scripts are recorded, nothing more is and the form is + /// marked truncated. fn push_events(&mut self, som: &str, scripts: &[Script], calculate: Option<&Script>, validate: Option<&Script>, message: Option<&str>) { if self.events.len() >= MAX_EVENTS { self.truncated = true; return; } - for s in scripts { + let all = scripts + .iter() + .map(|s| (s, s.activity.as_str(), None)) + .chain(calculate.map(|c| (c, "calculate", None))) + .chain(validate.map(|v| (v, "validate", message))); + for (s, activity, message) in all { if s.text.trim().is_empty() { continue; } + match self.script_bytes.checked_add(s.text.len()).filter(|n| *n <= MAX_EVENT_SCRIPT_BYTES) { + Some(n) => self.script_bytes = n, + None => { + self.truncated = true; + return; + } + } self.events.push(ScriptEvent { som: som.to_string(), - activity: s.activity.clone(), + activity: activity.to_string(), script: s.text.clone(), formcalc: s.formcalc, - message: None, - }); - } - if let Some(c) = calculate - && !c.text.trim().is_empty() - { - self.events.push(ScriptEvent { - som: som.to_string(), - activity: "calculate".into(), - script: c.text.clone(), - formcalc: c.formcalc, - message: None, - }); - } - if let Some(v) = validate - && !v.text.trim().is_empty() - { - self.events.push(ScriptEvent { - som: som.to_string(), - activity: "validate".into(), - script: v.text.clone(), - formcalc: v.formcalc, message: message.map(str::to_string), }); } @@ -410,7 +406,7 @@ impl Walker<'_> { /// from the template and `ov`) and every scripted event, in document order, within /// [`MAX_FORM_NODES`] objects. pub fn form_tree(tpl: &Template, data: Option<&DataNode>, ov: &Overrides) -> LiveForm { - let mut w = Walker { tpl, data, ov, events: Vec::new(), nodes: 0, truncated: false }; + let mut w = Walker { tpl, data, ov, events: Vec::new(), script_bytes: 0, nodes: 0, truncated: false }; let root = Node::Subform(Box::new(tpl.root.clone())); let mut nodes = w.children(std::slice::from_ref(&root), "", 0); let mut root_node = nodes.pop().unwrap_or_default(); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 31a368481..a101f219b 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2684,3 +2684,30 @@ fn embedded_field_values_expand_to_a_bounded_amount_of_text() { assert!(kept <= text::MAX_EMBEDDED, "{kept} bytes of embedded text laid out"); assert!(text::natural_width(&rich, &Para::default(), &face, &|_| value.clone()) <= face.width(&value) * 70.0); } + +/// Repeated subforms each carry their own copy of a long script: the live form holds a bounded +/// amount of script text and says it left the rest out. +#[test] +fn repeated_script_events_hold_a_bounded_amount_of_script() { + let script = format!("var a = 1; //{}", "x".repeat(32 * 1024)); + let xml = format!( + r##" + +"## + ); + let (tpl, _) = parse(&xml).unwrap(); + let live = form_tree(&tpl, None, &Overrides::default()); + assert!(!live.events.is_empty()); + let held: usize = live.events.iter().map(|e| e.script.len()).sum(); + assert!(held <= crate::script::MAX_EVENT_SCRIPT_BYTES, "{held} bytes of script held by {} events", live.events.len()); + assert!(live.truncated, "{held} bytes, {} events", live.events.len()); +} From a07ad60d529dce2c68f355ab84038b1d819f46fa Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:25:19 +0000 Subject: [PATCH 058/142] fix(xfa): queue each added data instance once per pass write_data_ops expanded every instance-count op into one queued node per instance, then deduplicated the pass. A script adding rows one at a time asks for one more instance each time, so n rows queued about n*n/2 nodes before the deduplication. Each op queues only the instances the pass has not queued yet, and a budget bounds the instances queued across passes that removals separate, with a warning past it. --- crates/xfa/src/data.rs | 32 ++++++++++++++++++++++++++++++-- crates/xfa/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 7c2efaf5e..87db6b998 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -1516,6 +1516,16 @@ fn element_markup(src: &str, n: roxmltree::Node) -> Option { Some(format!("{}{decls}{}", text.get(..name_end)?, text.get(name_end..)?)) } +/// Most instance nodes one [`write_data_ops`] queues over all its passes (a removal starts a new +/// pass, which asks for the instances again). +const MAX_ENSURED: usize = 200_000; + +// Instance nodes write_data_ops has queued on this thread, for tests that bound its work. +#[cfg(test)] +thread_local! { + pub(crate) static ENSURED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script /// event changed becomes a single new datasets stream. When the packet is already the stream /// `reuse` (written earlier for the same event), that stream is replaced in place instead of @@ -1525,6 +1535,9 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) let mut steps: Vec> = Vec::new(); let mut removes: Vec> = Vec::new(); let mut run: Vec = Vec::new(); + // Instances queued in this run, by parent and name: asking for more only queues the rest. + let mut queued: HashMap<(&DataPath, &str), usize> = HashMap::new(); + let (mut ensured, mut capped) = (0usize, false); let flush = |run: &mut Vec, steps: &mut Vec>, removes: &mut Vec>| { if !run.is_empty() { let mut seen = std::collections::HashSet::new(); @@ -1541,7 +1554,17 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false }); } DataOp::Instances { parent, name, count } => { - for k in 0..(*count).min(MAX_INDEX) { + let done = queued.entry((parent, name.as_str())).or_insert(0); + while *done < (*count).min(MAX_INDEX) { + if ensured >= MAX_ENSURED { + capped = true; + break; + } + ensured += 1; + #[cfg(test)] + ENSURED.with(|n| n.set(n.get() + 1)); + let k = *done; + *done += 1; let mut path = parent.clone(); path.push((name.clone(), k)); run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true }); @@ -1549,6 +1572,7 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) } DataOp::Remove(path) if !path.is_empty() => { flush(&mut run, &mut steps, &mut removes); + queued.clear(); steps.push(Vec::new()); removes.push(Some(path.clone())); } @@ -1564,7 +1588,11 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) None => Op::Values(w), }) .collect(); - rewrite(doc, &plan, reuse) + let mut written = rewrite(doc, &plan, reuse)?; + if capped { + written.warnings.push(format!("XFA data instances not written past {MAX_ENSURED}: the rows were added and removed too often")); + } + Ok(written) } /// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index a101f219b..9aebee5bf 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2711,3 +2711,34 @@ fn repeated_script_events_hold_a_bounded_amount_of_script() { assert!(held <= crate::script::MAX_EVENT_SCRIPT_BYTES, "{held} bytes of script held by {} events", live.events.len()); assert!(live.truncated, "{held} bytes, {} events", live.events.len()); } + +/// Rows added one at a time (each op asking for one more instance) queue each instance node +/// once, and removals in between don't let the queue grow without bound. +#[test] +fn added_instances_are_queued_once_each() { + use crate::data::{DataOp, ENSURED, write_data_ops}; + let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap(); + render_into(&mut doc).unwrap(); + let table = som_to_path("form[0].page1[0].table[0]"); + let ops: Vec = (1..=1000).map(|count| DataOp::Instances { parent: table.clone(), name: "row".into(), count }).collect(); + ENSURED.with(|n| n.set(0)); + let r = write_data_ops(&mut doc, &ops, None).unwrap(); + assert!(r.written, "{r:?}"); + assert_eq!(crate::data_of(&doc).unwrap().count(&table, "row"), 1000); + let queued = ENSURED.with(|n| n.get()); + assert!(queued <= 2_000, "{queued} instance nodes queued for 1000 rows"); + // Removals split the ops into separate passes; together they stay within the budget. + let ops: Vec = (0..300) + .flat_map(|_| { + [ + DataOp::Instances { parent: table.clone(), name: "row".into(), count: 1000 }, + DataOp::Remove(som_to_path("form[0].page1[0].table[0].row[999]")), + ] + }) + .collect(); + ENSURED.with(|n| n.set(0)); + let r = write_data_ops(&mut doc, &ops, None).unwrap(); + let queued = ENSURED.with(|n| n.get()); + assert!(queued <= 200_000, "{queued} instance nodes queued"); + assert!(r.warnings.iter().any(|w| w.contains("instances")), "{r:?}"); +} From bac0e610db7a314680c6790929e0e6c23a57a5a5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:25:57 +0000 Subject: [PATCH 059/142] fix(xfa): bound the bytes a data import copies out of its file Converting an imported data file copied each element's and attribute's resolved namespace URI, so a long namespace declared once on the root was copied into every element that inherits it; only the node count was limited. The import counts the names, namespaces, attributes and text it copies against a byte budget of four times the file-size limit, and refuses the file as too large past it. --- crates/xfa/src/data.rs | 64 +++++++++++++++++++++++++++-------------- crates/xfa/src/tests.rs | 16 +++++++++++ 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 87db6b998..21c12a58b 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -801,19 +801,52 @@ pub struct ImportNode { /// Deepest data an import keeps. const MAX_IMPORT_DEPTH: usize = MAX_DEPTH; -/// `n` as an [`ImportNode`], counting nodes against `budget`; `None` past the depth or node -/// limits. -fn import_node(n: roxmltree::Node, depth: usize, budget: &mut usize) -> Option { - if depth > MAX_IMPORT_DEPTH || *budget == 0 { - return None; +/// Most bytes of names, namespaces, attributes and text an import copies out of its file: a +/// namespace declared once is copied into every element and attribute that inherits it. +pub(crate) const MAX_IMPORT_BYTES: usize = 4 * MAX_IMPORT; + +/// What an import may still copy: nodes and bytes. +struct ImportBudget { + nodes: usize, + bytes: usize, +} + +impl ImportBudget { + fn new() -> ImportBudget { + ImportBudget { nodes: MAX_NODES as usize, bytes: MAX_IMPORT_BYTES } + } + + fn take_bytes(&mut self, n: usize) -> Result<(), XfaError> { + self.bytes = self + .bytes + .checked_sub(n) + .ok_or_else(|| XfaError::TooLarge(format!("the data file expands to more than {} MB", MAX_IMPORT_BYTES >> 20)))?; + Ok(()) + } +} + +/// `n` as an [`ImportNode`], counting nodes and copied bytes against `budget`; `TooLarge` past +/// the depth or either budget. +fn import_node(n: roxmltree::Node, depth: usize, budget: &mut ImportBudget) -> Result { + if depth > MAX_IMPORT_DEPTH { + return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); + } + budget.nodes = budget.nodes.checked_sub(1).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?; + let name = n.tag_name().name(); + let ns = n.tag_name().namespace(); + budget.take_bytes(name.len().saturating_add(ns.map_or(0, str::len)))?; + let mut attrs = Vec::new(); + for a in n.attributes().filter(|a| a.name() != "xmlns" && a.namespace() != Some("http://www.w3.org/2000/xmlns/")) { + budget.take_bytes(a.namespace().map_or(0, str::len).saturating_add(a.name().len()).saturating_add(a.value().len()))?; + attrs.push((a.namespace().map(str::to_string), a.name().to_string(), a.value().to_string())); } - *budget -= 1; let mut children = Vec::new(); let mut text = String::new(); for c in n.children() { if c.is_element() { children.push(import_node(c, depth + 1, budget)?); } else if let Some(t) = c.text() { + budget.take_bytes(t.len())?; text.push_str(t); } } @@ -821,17 +854,7 @@ fn import_node(n: roxmltree::Node, depth: usize, budget: &mut usize) -> Option) -> Result; this form's data root is <{want}>"))); } - let mut budget = MAX_NODES as usize; - let node = import_node(root, 0, &mut budget).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?; + let node = import_node(root, 0, &mut ImportBudget::new())?; return Ok(vec![node]); } }; - let mut budget = MAX_NODES as usize; + let mut budget = ImportBudget::new(); let mut out = Vec::new(); for c in data.children().filter(|c| c.is_element()) { - out.push(import_node(c, 1, &mut budget).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?); + out.push(import_node(c, 1, &mut budget)?); } Ok(out) } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 9aebee5bf..bf8872ad5 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2742,3 +2742,19 @@ fn added_instances_are_queued_once_each() { assert!(queued <= 200_000, "{queued} instance nodes queued"); assert!(r.warnings.iter().any(|w| w.contains("instances")), "{r:?}"); } + +/// A namespace declared once and inherited by many imported elements is not copied into each +/// past the import's byte budget. +#[test] +fn an_inherited_namespace_does_not_multiply_an_import() { + use crate::data::parse_data_file; + let uri = format!("urn:{}", "n".repeat(70_000)); + let xml = format!("{}", "".repeat(1_000)); + fn ns_bytes(nodes: &[crate::data::ImportNode]) -> usize { + nodes.iter().map(|n| n.ns.as_ref().map_or(0, String::len) + ns_bytes(&n.children)).sum() + } + match parse_data_file(&xml, None) { + Ok(nodes) => assert!(ns_bytes(&nodes) <= crate::data::MAX_IMPORT_BYTES, "{} bytes of namespaces copied", ns_bytes(&nodes)), + Err(e) => assert!(matches!(e, XfaError::TooLarge(_)), "{e:?}"), + } +} From ee333376277ee7f78d17663bd11f081bae4a5df5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:26:28 +0000 Subject: [PATCH 060/142] fix(xfa): bound the name bytes of data nodes a write creates Writing a value at a high index creates every missing sibling before it, each with its own copy of the name. The node budget counted siblings but not their names, so a long name with a high index made thousands of copies of it. The write's budget also counts the bytes of the names it creates; a path past it is skipped with the existing warning. --- crates/xfa/src/data.rs | 37 ++++++++++++++++++++++++++----------- crates/xfa/src/tests.rs | 13 +++++++++++++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 21c12a58b..2b80d8b96 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -519,6 +519,21 @@ fn escape(s: &str) -> (String, bool) { const MAX_NEW_NODES: usize = 20_000; /// Highest same-name sibling index a write follows. const MAX_INDEX: usize = 10_000; +/// Most bytes of names the nodes one write creates hold together: every sibling an index +/// skips over gets its own copy of the name. +const MAX_NEW_NAME_BYTES: usize = 4 << 20; + +/// What one write may still create: nodes and bytes of their names. +struct NewNodes { + nodes: usize, + name_bytes: usize, +} + +impl NewNodes { + fn new() -> NewNodes { + NewNodes { nodes: MAX_NEW_NODES, name_bytes: MAX_NEW_NAME_BYTES } + } +} /// A data tree being built for writing. #[derive(Default)] @@ -533,8 +548,8 @@ struct Build { impl Build { /// The node at `path` below this one, created as needed. `None` when the path is deeper /// than [`MAX_DEPTH`], an index is above [`MAX_INDEX`], or creating it would spend more - /// than `budget` new nodes. - fn at(&mut self, path: &[(String, usize)], budget: &mut usize) -> Option<&mut Build> { + /// new nodes or name bytes than `budget` holds. + fn at(&mut self, path: &[(String, usize)], budget: &mut NewNodes) -> Option<&mut Build> { if path.len() > MAX_DEPTH { return None; } @@ -547,15 +562,15 @@ impl Build { let have = node.by_name.get(&name).map_or(0, Vec::len); if *idx >= have { let need = idx - have + 1; - if need > *budget { + let bytes = need.saturating_mul(name.len()); + if need > budget.nodes || bytes > budget.name_bytes { return None; } - *budget -= need; - for _ in 0..need { - let pos = node.children.len(); - node.by_name.entry(name.clone()).or_default().push(pos); - node.children.push(Build { name: name.clone(), ..Default::default() }); - } + budget.nodes -= need; + budget.name_bytes -= bytes; + let start = node.children.len(); + node.children.extend((0..need).map(|_| Build { name: name.clone(), ..Default::default() })); + node.by_name.entry(name.clone()).or_default().extend(start..start + need); } let pos = *node.by_name.get(&name)?.get(*idx)?; node = node.children.get_mut(pos)?; @@ -675,7 +690,7 @@ const CONTROL: &str = "Characters XML can't hold were left out of the XFA data o /// Fresh data for `wants`: the children of an `xfa:data` element. fn build_children(wants: &[Want], warnings: &mut Vec) -> String { let mut root = Build::default(); - let mut budget = MAX_NEW_NODES; + let mut budget = NewNodes::new(); for w in wants { match root.at(&w.path, &mut budget) { Some(node) => node.text = w.text.clone(), @@ -1095,7 +1110,7 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve let tree = data.document(); // Nodes to add, per existing element they go into. let mut added: Vec<(roxmltree::NodeId, Build)> = Vec::new(); - let mut budget = MAX_NEW_NODES; + let mut budget = NewNodes::new(); for w in wants { let (escaped, dropped) = escape(&w.text); let mut node = data; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index bf8872ad5..a4ebacafd 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2758,3 +2758,16 @@ fn an_inherited_namespace_does_not_multiply_an_import() { Err(e) => assert!(matches!(e, XfaError::TooLarge(_)), "{e:?}"), } } + +/// A long data name with a high index is not copied into every sibling created before it. +#[test] +fn long_names_repeated_by_an_index_are_capped() { + let doc = Document::open(Arc::new(static_shell(""))).unwrap(); + let fields = vec![FieldDatum { + obj: pdfcraft_cos::ObjRef::new(1000, 0), + name: format!("form1[0].{}[9999]", "n".repeat(2_000)), + data: FieldData::Text("v".into()), + }]; + let data = build_data(&doc, &fields); + assert!(data.len() < 2_000_000, "{} bytes of data built", data.len()); +} From 4aad36cf199aa7d5dd568a695dd810e6dc665a43 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:05:41 +0000 Subject: [PATCH 061/142] fix(create): refuse an indexed colour space whose base is indexed before following it Image export parsed an Indexed colour space's base before checking whether that base was itself Indexed. A base that refers back to the same array recursed until the stack overflowed. The base is now checked first; since Indexed is the only colour space that recurses, this also ends any cycle. --- crates/create/src/extract.rs | 11 +++++++++-- crates/create/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 768da4d1f..bd5ab5271 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -383,10 +383,17 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result { Some(b"CalGray") => Ok(Space::Gray), Some(b"CalRGB") => Ok(Space::Rgb), Some(b"Indexed" | b"I") => { - let base = space(doc, a.get(1))?; - if matches!(base, Space::Indexed(..)) { + // Checked before recursing: Indexed is the only space that recurses, so refusing + // an Indexed base here also ends a cyclic base reference. + let nested = match a.get(1).map(|b| doc.resolve(b)).as_deref() { + Some(Object::Array(b)) => matches!(b.first().and_then(Object::as_name), Some(b"Indexed" | b"I")), + Some(Object::Name(n)) => matches!(n.as_slice(), b"Indexed" | b"I"), + _ => false, + }; + if nested { return Err("nested indexed colour spaces aren't valid".into()); } + let base = space(doc, a.get(1))?; let hival = match a.get(2).map(|value| doc.resolve(value)).as_deref() { Some(Object::Int(value)) if (0..=255).contains(value) => *value as u8, _ => return Err("an indexed colour space with an invalid hival".into()), diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 635610569..a21497767 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -1181,3 +1181,19 @@ fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() { let img = image_of(&doc, 1); assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20))); } + +#[test] +fn a_cyclic_indexed_colour_space_is_refused() { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + // 5 0 obj [/Indexed 5 0 R 255 <00>]: the base is the indexed space itself. + let cs = doc.add(Object::Null); + doc.set(cs, Object::Array(vec![Object::name("Indexed"), Object::Ref(cs), Object::Int(255), Object::String(PdfString::literal(vec![0]))])); + with_image(&mut doc, |_| { + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"ColorSpace".to_vec(), Object::Ref(cs)); + Stream::from_raw(dict, vec![0]) + }); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty()); + assert_eq!(out.skipped.len(), 1, "{:?}", out.skipped); +} From 78a562bc081b8b2afad687e2399071c003ae9a19 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:08:32 +0000 Subject: [PATCH 062/142] fix(optimize): bound a merged fragment image's height before sizing its buffer The fragment merge multiplied the merged width by a height cast from a float without checking either fit. A tiny fragment rectangle gives a huge height: the multiplication overflowed (a panic in debug builds, a wrap past the pixel limit in release builds) and the u32 cast saturated, sizing an enormous allocation. The height must now fit u32 before the pixel limit is checked, and the buffer size uses checked multiplication. --- crates/optimize/src/fragments.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index b5967eee0..caf617f71 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -143,12 +143,16 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, u32, u3 // Pixel rows per unit of height: the finest fragment's. let density = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max); let height = ((top - bottom) * density).round(); - if !(height.is_finite() && height >= 1.0) || (width as u64) * (height as u64) > MAX_PIXELS { + if !(height.is_finite() && height >= 1.0 && height <= f64::from(u32::MAX)) { return None; } + // In range and whole, so the cast is exact. let height = height as u32; - let row = width as usize * n; - let mut out = vec![255u8; row * height as usize]; + if u64::from(width) * u64::from(height) > MAX_PIXELS { + return None; + } + let row = (width as usize).checked_mul(n)?; + let mut out = vec![255u8; row.checked_mul(height as usize)?]; for f in run { let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height); let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height); @@ -297,3 +301,21 @@ pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, pro } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_merged_height_beyond_u32_is_refused_before_allocating() { + // Two fragments, one pixel row each, the top one 2^-48 high: the merged image would be + // 2^16 × 2^48 pixels, whose product wraps to 0 in u64. + let image = ObjRef { num: 1, generation: 0 }; + let edge = 1.0 - (-48f64).exp2(); + let run = [ + Fragment { image, rect: [0.0, edge, 1.0, 1.0], width: 1 << 16, height: 1 }, + Fragment { image, rect: [0.0, 0.0, 1.0, edge], width: 1 << 16, height: 1 }, + ]; + assert!(merge(&Document::new_empty(), &run, 3).is_none()); + } +} From f0e3880c35327859f82228caaa8219d86b8955c0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:12:52 +0000 Subject: [PATCH 063/142] fix(xfdf): never let a same-named imported comment replace a form widget Comment import replaces an existing annotation with the same /NM in place. The check for annotations it must keep covered locked comments and redaction marks but not widgets or field dictionaries, so an imported comment could overwrite a field's widget object, setting its value and validation entries without the form value setter while the AcroForm field list still pointed at it. Widgets and field dictionaries are now kept, and the imported comment is added beside them under no name. --- crates/xfdf/src/lib.rs | 11 ++++++----- crates/xfdf/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index ab258990e..1e037b679 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1017,10 +1017,11 @@ const REDACT_UNSEEN: i64 = 1 | 2 | 32; /// `/F` Locked (ISO 32000-2 Table 167): the user protected this annotation from change. const LOCKED: i64 = 128; -/// An existing annotation an import must never replace, move or unlock: one the user locked, or a -/// redaction mark (replacing it would drop the region the user marked to remove). +/// An existing annotation an import must never replace, move or unlock: one the user locked, a +/// redaction mark (replacing it would drop the region the user marked to remove), or a form +/// widget or field (replacing it would set the field's value and policy around the form setter). fn import_protected(x: &Dict) -> bool { - x.int(b"F").unwrap_or(0) & LOCKED != 0 || x.name(b"Subtype") == Some(b"Redact".as_slice()) + x.int(b"F").unwrap_or(0) & LOCKED != 0 || matches!(x.name(b"Subtype"), Some(b"Redact" | b"Widget")) || x.contains(b"FT") } fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { @@ -1042,8 +1043,8 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result = Vec::new(); - // Never let a same-name import replace a locked comment or a redaction mark: keep it, and add - // the imported dict beside it under no name of its own. + // Never let a same-name import replace a protected annotation (`import_protected`): keep it, + // and add the imported dict beside it under no name of its own. let mut clash = false; for a in annots { let existing = a.as_ref().and_then(|r| doc.get(r).as_dict().cloned()); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 6c9824a24..57155e4b7 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1660,3 +1660,22 @@ fn tabular_import_keeps_tsv_quotes_literal_and_csv_value_tabs() { import(&mut doc, b"Name,Other\r\n\"Ada\tLovelace\",other field\r\n").unwrap(); assert_eq!(fields(&doc).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["Ada\tLovelace"]); } + +#[test] +fn a_same_named_comment_import_never_replaces_a_form_widget() { + let mut doc = blank(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let widget = doc + .resolve(page.dict.get(b"Annots").unwrap()) + .as_array() + .unwrap() + .iter() + .filter_map(Object::as_ref) + .find(|r| doc.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget".as_slice()))) + .unwrap(); + doc.update_dict(widget, |d| d.set(b"NM".to_vec(), PdfString::text("w1"))).unwrap(); + let before = doc.get(widget); + let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Text /NM (w1) /Page 0 /Rect [0 0 1 1] /FT /Tx /T (Name) /V (forged) >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + assert_eq!(import(&mut doc, fdf).unwrap().comments, 1); + assert_eq!(doc.get(widget), before, "the widget was overwritten by an imported comment"); +} From d266222339adf378f785c98fdd3ae65eb8831867 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:22:02 +0000 Subject: [PATCH 064/142] fix(xfdf): draw every imported redaction mark's review outline, opaque A redaction mark's outline is drawn from its /QuadPoints, but applying the mark falls back to its /Rect when it has none. An imported mark with only a /Rect, or with quads the outline can't draw, therefore had no visible outline while still removing content when applied, and an imported opacity of 0 hid the outline too. Imported marks now get quads from their /Rect when their own are unusable (or none, so they remove nothing), and their /CA and /ca are dropped. --- crates/xfdf/src/lib.rs | 21 +++++++++++++++++++++ crates/xfdf/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 1e037b679..80c37b5e8 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1032,6 +1032,27 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { + let (l, r, b, t) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1)); + d.set(b"QuadPoints".to_vec(), arr(&[l, t, r, t, l, b, r, b])); + } + _ => { + d.remove(b"QuadPoints"); + } + } + } } let nm = d.get(b"NM").and_then(|n| n.as_string().map(|s| s.to_text())); let annots: Vec = doc diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 57155e4b7..7951bef4d 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1679,3 +1679,26 @@ fn a_same_named_comment_import_never_replaces_a_form_widget() { assert_eq!(import(&mut doc, fdf).unwrap().comments, 1); assert_eq!(doc.get(widget), before, "the widget was overwritten by an imported comment"); } + +#[test] +fn an_imported_rect_only_or_transparent_redaction_mark_is_drawn_for_review() { + let xfdf = br#""#; + let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Redact /NM (r1) /Page 0 /Rect [60 600 560 620] /CA 0 /ca 0 >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + for data in [xfdf.as_slice(), fdf.as_slice()] { + let mut doc = blank(); + assert_eq!(import(&mut doc, data).unwrap().comments, 1); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let mark = doc + .resolve(page.dict.get(b"Annots").unwrap()) + .as_array() + .unwrap() + .iter() + .filter_map(|a| a.as_ref().and_then(|r| doc.get(r).as_dict().cloned())) + .find(|d| d.name(b"Subtype") == Some(b"Redact".as_slice())) + .unwrap(); + assert_eq!(nums_of(&doc, mark.get(b"QuadPoints")), vec![60.0, 620.0, 560.0, 620.0, 60.0, 600.0, 560.0, 600.0]); + let ap = mark.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()); + assert!(ap.is_some_and(|ap| ap.get(b"N").is_some()), "the mark has no review appearance: {mark:?}"); + assert!(mark.get(b"CA").is_none() && mark.get(b"ca").is_none(), "the mark can be drawn transparent: {mark:?}"); + } +} From 8a8dab6c00ad27e9d3e1b66e42442c1fbd930ba7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:40 +0000 Subject: [PATCH 065/142] fix(xfdf): keep merged spreadsheet cells from starting a formula Merge Data Files into Spreadsheet writes field names and values from other people's files as CSV cells, escaping only the delimiters. A cell starting with =, +, - or @ is evaluated as a formula when the CSV is opened in a spreadsheet program. Such cells, after any leading whitespace, are now written with a leading apostrophe; finite numbers such as -12.5 are left as they are. --- crates/xfdf/src/lib.rs | 18 +++++++++++++++++- crates/xfdf/src/tests.rs | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 80c37b5e8..1eac1f96c 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1287,6 +1287,10 @@ pub fn data_values(bytes: &[u8]) -> Result)>, DataError /// Merge Data Files into Spreadsheet: one CSV row per file, one column per field name (in the /// order they first appear). +/// +/// The names and values come from other people's files, and spreadsheet programs evaluate a cell +/// that starts with `=`, `+`, `-` or `@` (after any leading whitespace) as a formula. Such a cell +/// is written with a leading `'`, which keeps it text; a plain number such as `-12.5` is not. pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { let mut columns: Vec<&str> = Vec::new(); for f in files { @@ -1296,7 +1300,19 @@ pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { } } } - let q = |s: &str| if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let literal = |s: &str| { + let t = s.trim_start(); + let formula = match t.chars().next() { + Some('=' | '@') => true, + Some('+' | '-') => !t.parse::().is_ok_and(f64::is_finite), + _ => false, + }; + if formula { format!("'{s}") } else { s.to_string() } + }; + let q = |s: &str| { + let s = literal(s); + if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s } + }; let mut out = columns.iter().map(|c| q(c)).collect::>().join(","); out.push('\n'); for f in files { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 7951bef4d..3e6dc58a5 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1702,3 +1702,18 @@ fn an_imported_rect_only_or_transparent_redaction_mark_is_drawn_for_review() { assert!(mark.get(b"CA").is_none() && mark.get(b"ca").is_none(), "the mark can be drawn transparent: {mark:?}"); } } + +#[test] +fn merged_spreadsheet_cells_never_start_a_formula() { + let row = vec![ + ("=HYPERLINK(\"http://x\")".to_string(), vec!["@SUM(A1)".to_string()]), + ("Total".to_string(), vec!["-2+3".to_string()]), + ("Plus".to_string(), vec!["\t+1+cmd".to_string()]), + ("Balance".to_string(), vec!["-12.5".to_string()]), + ("\tName".to_string(), vec!["Ada".to_string()]), + ]; + let csv = merge_csv(&[row]); + let lines: Vec<&str> = csv.lines().collect(); + assert_eq!(lines[0], "\"'=HYPERLINK(\"\"http://x\"\")\",Total,Plus,Balance,\"\tName\""); + assert_eq!(lines[1], "'@SUM(A1),'-2+3,\"'\t+1+cmd\",-12.5,Ada"); +} From 63f7932d06e19b1b9f9a1cb4baaab3550578bb4a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:00:43 +0000 Subject: [PATCH 066/142] fix(xfdf): fall back to the /Rect when an imported redaction mark has a zero-area quad A quad whose corners all lie on one line (or on one point) encloses nothing, yet such /QuadPoints counted as drawable, replaced the mark's /Rect and left an imported redaction that covers nothing. Any quad without area now makes the mark's quads unusable, so its /Rect is used instead. --- crates/xfdf/src/lib.rs | 22 ++++++++++++++++------ crates/xfdf/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 1eac1f96c..9582298b3 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -246,6 +246,14 @@ fn nums_of(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() } +/// Whether a quad's four corners enclose any area: some three of them are not collinear. +fn quad_has_area(q: &[f64; 8]) -> bool { + let [ax, ay, bx, by, cx, cy, dx, dy] = *q; + let cross = |(px, py): (f64, f64), (qx, qy): (f64, f64), (rx, ry): (f64, f64)| (qx - px) * (ry - py) - (qy - py) * (rx - px); + let (a, b, c, d) = ((ax, ay), (bx, by), (cx, cy), (dx, dy)); + [cross(a, b, c), cross(a, b, d), cross(a, c, d), cross(b, c, d)].iter().any(|v| v.abs() > 0.0) +} + fn text_of(doc: &Document, d: &Dict, k: &[u8]) -> Option { d.get(k).and_then(|o| match &*doc.resolve(o) { Object::String(s) => Some(s.to_text()), @@ -1036,13 +1044,15 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result = q.iter().filter_map(Object::as_f64).filter(|v| v.is_finite()).collect(); + let (quads, rest) = v.as_chunks::<8>(); + v.len() == q.len() && rest.is_empty() && !quads.is_empty() && quads.iter().all(quad_has_area) + }); if !quads_drawable { - // Quads the outline can't draw are replaced by the /Rect's, or dropped when there is - // no usable /Rect either; such a mark then removes nothing. + // Quads the outline can't draw, or any quad that encloses no area, are replaced by the + // /Rect's, or dropped when there is no usable /Rect either; such a mark then removes + // nothing. match nums_of(doc, d.get(b"Rect"))[..] { [x0, y0, x1, y1] if [x0, y0, x1, y1].iter().all(|v| v.is_finite()) => { let (l, r, b, t) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1)); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 3e6dc58a5..de880fe66 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1717,3 +1717,37 @@ fn merged_spreadsheet_cells_never_start_a_formula() { assert_eq!(lines[0], "\"'=HYPERLINK(\"\"http://x\"\")\",Total,Plus,Balance,\"\tName\""); assert_eq!(lines[1], "'@SUM(A1),'-2+3,\"'\t+1+cmd\",-12.5,Ada"); } + +fn imported_redact_mark(data: &[u8]) -> Dict { + let mut doc = blank(); + assert_eq!(import(&mut doc, data).unwrap().comments, 1); + let page = pdfcraft_model::pages(&doc)[0].clone(); + doc.resolve(page.dict.get(b"Annots").unwrap()) + .as_array() + .unwrap() + .iter() + .filter_map(|a| a.as_ref().and_then(|r| doc.get(r).as_dict().cloned())) + .find(|d| d.name(b"Subtype") == Some(b"Redact".as_slice())) + .unwrap() +} + +#[test] +fn an_imported_redaction_mark_with_zero_area_quads_falls_back_to_its_rect() { + let rect_quads = vec![60.0, 620.0, 560.0, 620.0, 60.0, 600.0, 560.0, 600.0]; + let fdf = |quads: &str| { + format!( + "%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Redact /NM (r1) /Page 0 /Rect [60 600 560 620] /QuadPoints [{quads}] >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n" + ) + }; + let cases = [ + fdf("100 100 100 100 100 100 100 100").into_bytes(), + // A good quad beside one that is a line (all four corners collinear). + fdf("60 620 560 620 60 600 560 600 10 10 20 20 30 30 40 40").into_bytes(), + br#""#.to_vec(), + ]; + for data in cases { + let mark = imported_redact_mark(&data); + let quads: Vec = mark.get(b"QuadPoints").and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect(); + assert_eq!(quads, rect_quads, "zero-area quads were kept: {mark:?}"); + } +} From 256097023c57cc0574287d338051c88676b72096 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:01:55 +0000 Subject: [PATCH 067/142] fix(xfdf): use an imported redaction mark's /Rect only when it is four finite numbers The /Rect fallback dropped non-numbers before checking the length, so a malformed /Rect such as [0 0 /Bad 100 100] read as a valid rectangle and gave the mark quads. The array itself must now hold exactly four finite numbers. --- crates/xfdf/src/lib.rs | 13 +++++++++++-- crates/xfdf/src/tests.rs | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 9582298b3..219b0de81 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -246,6 +246,15 @@ fn nums_of(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() } +/// A `/Rect` of exactly four finite numbers; any other entry makes the whole rectangle unusable. +fn rect_strict(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { + let num = |x: &Object| doc.resolve(x).as_f64().filter(|v| v.is_finite()); + match doc.resolve(o?).as_array()?.as_slice() { + [x0, y0, x1, y1] => Some([num(x0)?, num(y0)?, num(x1)?, num(y1)?]), + _ => None, + } +} + /// Whether a quad's four corners enclose any area: some three of them are not collinear. fn quad_has_area(q: &[f64; 8]) -> bool { let [ax, ay, bx, by, cx, cy, dx, dy] = *q; @@ -1053,8 +1062,8 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { + match rect_strict(doc, d.get(b"Rect")) { + Some([x0, y0, x1, y1]) => { let (l, r, b, t) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1)); d.set(b"QuadPoints".to_vec(), arr(&[l, t, r, t, l, b, r, b])); } diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index de880fe66..c98fab06a 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1751,3 +1751,10 @@ fn an_imported_redaction_mark_with_zero_area_quads_falls_back_to_its_rect() { assert_eq!(quads, rect_quads, "zero-area quads were kept: {mark:?}"); } } + +#[test] +fn an_imported_redaction_rect_with_a_non_number_is_not_used() { + let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Redact /NM (r1) /Page 0 /Rect [0 0 /Bad 100 100] >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + let mark = imported_redact_mark(fdf); + assert!(mark.get(b"QuadPoints").is_none(), "quads were drawn from a malformed /Rect: {mark:?}"); +} From f1c56ef86534ec182cde58cc827a700e0a693f51 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:45:44 +0000 Subject: [PATCH 068/142] fix(redact): count a name tree's shared nodes once The hidden-information count followed every /Kids entry of the attachment and document-script name trees with only a depth limit, so a node listed twice by each level was read once per path: a few hundred bytes made the scan take 2^32 steps. Indirect nodes are now read once each, under a total node budget. --- crates/redact/src/sanitize.rs | 34 ++++++++++++++++++++++++++++------ crates/redact/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 83db13d72..c9c2b182a 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -87,14 +87,36 @@ fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Option { d.get(key).and_then(|o| doc.resolve(o).as_dict().cloned()) } -/// Entries of a name tree (`/Names` leaves, `/Kids` recursion), bounded against cycles. -fn name_tree_len(doc: &Document, node: &Dict, depth: usize) -> usize { - if depth > 32 { +/// The most name-tree nodes one count reads. +const NAME_TREE_NODE_BUDGET: usize = 100_000; + +/// Entries of a name tree (`/Names` leaves, `/Kids` recursion). Each indirect node is read once, +/// however many `/Kids` arrays list it, so shared or cyclic kids cost one step per node rather +/// than one per path; depth and a total node budget bound the rest. +fn name_tree_len(doc: &Document, node: &Dict) -> usize { + let (mut seen, mut visited) = (std::collections::HashSet::new(), 0); + name_tree_len_seen(doc, node, 0, &mut seen, &mut visited) +} + +fn name_tree_len_seen(doc: &Document, node: &Dict, depth: usize, seen: &mut std::collections::HashSet, visited: &mut usize) -> usize { + *visited += 1; + if depth > 32 || *visited > NAME_TREE_NODE_BUDGET { return 0; } let leaves = node.get(b"Names").and_then(|n| doc.resolve(n).as_array().map(|a| a.len() / 2)).unwrap_or(0); let kids = node.get(b"Kids").and_then(|k| doc.resolve(k).as_array().cloned()).unwrap_or_default(); - leaves + kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).map(|k| name_tree_len(doc, &k, depth + 1)).sum::() + let mut n = leaves; + for k in &kids { + if let Object::Ref(r) = k + && !seen.insert(*r) + { + continue; + } + if let Some(kid) = doc.resolve(k).as_dict() { + n = n.saturating_add(name_tree_len_seen(doc, kid, depth + 1, seen, visited)); + } + } + n } /// The most distinct outline items one count reads, over all levels together. @@ -270,7 +292,7 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { let xmp = usize::from(cat.contains(b"Metadata")) + pages.iter().filter(|p| p.dict.contains(b"Metadata")).count(); info + xmp } - Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t, 0)) + count_sub(b"FileAttachment"), + Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + count_sub(b"FileAttachment"), Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), Hidden::FormFields => pdfcraft_forms::fields(doc).len(), Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), @@ -285,7 +307,7 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { + usize::from(cat.contains(b"AA")) + pages.iter().filter(|p| p.dict.contains(b"AA")).count() + annots.iter().filter(|a| a.contains(b"AA") || (a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A"))).count() - + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t, 0)) + + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) } Hidden::PrivateData => usize::from(cat.contains(b"PieceInfo")) + pages.iter().filter(|p| p.dict.contains(b"PieceInfo")).count(), }; diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 286667937..2a19014a9 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2472,3 +2472,25 @@ fn a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_under_a_mark() { assert_eq!(under(&mut doc, 0, &[[45.0, 95.0, 50.0, 110.0]]), 0, "nothing is drawn past LASTARR's 35pt width"); assert_eq!(under(&mut doc, 0, &[[10.0, 95.0, 45.0, 110.0]]), 7, "LASTARR's 7 glyphs, the one the page draws"); } + +/// A name tree whose levels each list the next level twice: a walk that follows every path +/// visits 2^levels nodes. +#[test] +fn a_name_tree_whose_nodes_share_kids_counts_each_entry_once() { + use crate::sanitize::{Hidden, scan}; + let levels = 12; + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 5 0 R >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b"BT ET"), + ]; + for k in 0..levels { + let next = 6 + k; + objs.push(format!("<< /Kids [{next} 0 R {next} 0 R] >>").into_bytes()); + } + objs.push(b"<< /Names [(a.txt) << /Type /Filespec /F (a.txt) >>] >>".to_vec()); + let doc = pdf(objs); + let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); + assert_eq!(counts[&Hidden::Attachments], 1, "one entry, reached through 2^{levels} paths"); +} From ee010fbbe5af7d7efcc8859b976a34121f3225d0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:18 +0000 Subject: [PATCH 069/142] fix(redact): read content strictly before sanitizing or redacting it Page and Form content was decoded tolerantly, keeping what came before a corrupt spot and dropping the rest without saying so. A page whose content broke at its first bytes therefore read as empty: the hidden-text scan counted nothing, the removal was skipped and the original stream, which other viewers still draw, stayed in the file. Content is now decoded strictly; an unreadable page counts as something to remove, so the removal runs and refuses the page, and a Form read only in part is treated like an undecodable one. --- crates/redact/src/interp.rs | 3 ++- crates/redact/src/lib.rs | 4 +++- crates/redact/src/sanitize.rs | 3 +++ crates/redact/src/tests.rs | 20 ++++++++++++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index c9a404b93..a608568f3 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -1201,7 +1201,8 @@ fn xobject( out.doomed.push(r); return Some(None); } - let data = match s.decoded() { + // Strict, like page content: a Form read only in part counts as undecodable. + let data = match s.decoded_strict() { Ok(d) => d, Err(_) if scope.mode == Mode::Apply => { scope.report.forms_removed += 1; diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6df5102af..364f7cc19 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -231,7 +231,9 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result< let mut data = Vec::new(); for o in &list { match &*doc.resolve(o) { - Object::Stream(s) => data.push(s.decoded().map_err(|_| RedactError::Unreadable(index + 1))?), + // Strict: a tolerant decode keeps what precedes corruption and drops the rest, which + // still draws in viewers that read further; anything not read can't be cleaned. + Object::Stream(s) => data.push(s.decoded_strict().map_err(|_| RedactError::Unreadable(index + 1))?), Object::Null => data.push(Vec::new()), _ => return Err(RedactError::Unreadable(index + 1)), } diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index c9c2b182a..f4c880e30 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -205,6 +205,9 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re if write { return Err(RedactError::Unreadable(pi + 1)); } + // Content that can't be read may hide anything: count it so the removal runs and + // refuses the page, instead of a zero count skipping it. + total += 1; continue; }; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 2a19014a9..a0d68af2d 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2494,3 +2494,23 @@ fn a_name_tree_whose_nodes_share_kids_counts_each_entry_once() { let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); assert_eq!(counts[&Hidden::Attachments], 1, "one entry, reached through 2^{levels} paths"); } + +/// Page content that stops decoding at its start (`z` inside an ASCII85 group) while the rest of +/// the stream draws hidden text. A tolerant decode reads nothing, counts nothing and keeps the +/// stream; sanitizing must refuse instead. +#[test] +fn hidden_text_in_content_that_stops_decoding_is_refused() { + use crate::sanitize::{Hidden, remove_hidden, sanitize}; + let encoded: &[u8] = br"!!z6<#'\7PQ#?0Ha>,+>Y->EZd(j+>PW)+B2ko-rWQ%;FOP6+B3(u78s~>"; + let fresh = || { + let mut doc = one_page(b"", "", vec![stream("/Filter /ASCII85Decode", encoded)]); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Contents".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap(); + doc + }; + let mut doc = fresh(); + let out = remove_hidden(&mut doc, &[Hidden::HiddenText]); + assert!(matches!(out, Err(RedactError::Unreadable(1))), "{out:?}"); + let mut doc = fresh(); + let out = sanitize(&mut doc); + assert!(matches!(out, Err(RedactError::Unreadable(1))), "{out:?}"); +} From 3fe9fe218ecbc4fb6ab2f9eba346115001bf0368 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:38 +0000 Subject: [PATCH 070/142] fix(redact): remove the widgets flattening keeps when removing form fields Removing form fields flattens them and then drops the interactive form, but flattening keeps hidden and no-view widgets (they draw nothing) and widgets with no appearance. The pages still listed them, so their values and their parent fields' values survived the full rewrite. Every widget left after flattening now leaves the pages and the structure tree, inline annotations included. --- crates/redact/src/sanitize.rs | 11 ++++++++--- crates/redact/src/tests.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index f4c880e30..41ef96ccc 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -341,7 +341,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0 { + let fields = which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0; + if fields { // Fields without appearances get one first, so their values stay visible. for f in pdfcraft_forms::fields(doc) { if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { @@ -354,10 +355,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result bool { let s = a.name(b"Subtype").unwrap_or(b""); (which.contains(&Hidden::Comments) && is_comment(s)) + || (fields && s == b"Widget") || (which.contains(&Hidden::Attachments) && s == b"FileAttachment") || (which.contains(&Hidden::LinksActionsScripts) && s == b"Link") }; @@ -374,7 +377,9 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result Object { + Object::Array(nums.iter().map(|&n| Object::Ref(ObjRef::new(n, 0))).collect()) +} + +/// An interactive form dictionary listing fields `nums`. +fn fields_dict(nums: &[u32]) -> Object { + let mut d = Dict::new(); + d.set(b"Fields".to_vec(), ref_array(nums)); + Object::Dict(d) +} + +/// Flattening keeps hidden widgets (they draw nothing); removing form fields must still take them +/// and their values, and their parent field's value, out of the file. +#[test] +fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = one_page( + b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET", + "", + vec![ + // 7 a hidden terminal field merged with its widget, 8 a parent field, 9 its hidden widget + b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (own) /V (LEAK_WIDGET_VALUE) /F 2 /Rect [10 200 150 220] /AP << /N 10 0 R >> >>".to_vec(), + b"<< /FT /Tx /T (parent) /V (LEAK_PARENT_VALUE) /Kids [9 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /F 32 /Rect [10 230 150 250] /AP << /N 10 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 140 20]", b"0 g"), + ], + ); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[7, 8]))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[7, 9]))).unwrap(); + remove_hidden(&mut doc, &[Hidden::FormFields]).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); +} From 750bbe27f1864bcb42b1cc1745256ae91d6db729 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:47:21 +0000 Subject: [PATCH 071/142] fix(redact): clean the structure tree after removing hidden content Removing hidden text or hidden layers rewrote page and Form content but left the structure tree alone, unlike applying redactions. An element's ActualText, Alt or expansion text still described the removed content, and a marked-content reference into a Form kept the original Form, with the removed text, in the full rewrite. The hidden-content pass now cleans the tags of what each page no longer shows and moves Form references to the rewritten copy, refusing when the tree can't be cleaned completely. --- crates/redact/src/sanitize.rs | 13 ++++++++++++- crates/redact/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 41ef96ccc..fea044951 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -254,7 +254,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let mut cleaned_page = page.dict.clone(); cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); let (_, page_content) = page_streams(doc, &cleaned_page, pi)?; - let mut res = resources; + let mut res = resources.clone(); if !out.xobjects.is_empty() || !out.replaced.is_empty() { let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); // A full save keeps whatever is still referenced: unlink the originals this page no @@ -270,6 +270,17 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); })?; + // Tags must not describe or point at what the page no longer shows. + let (changed_ids, empty_ids) = crate::tags::touched( + doc, + &resources, + pdfcraft_content::Pieces::join(&data).data(), + pdfcraft_content::Pieces::join(&page_content).data(), + ); + crate::tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + } + for (old, new, changed, empty) in out.tag_changes { + crate::tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; } } // What a page no longer draws must not stay reachable through an inherited or shared diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 4d2cc7601..5c0eece85 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2549,3 +2549,31 @@ fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file() assert_no_secret(&doc, b"LEAK_"); assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); } + +/// Removing hidden text cleans the structure tree as redaction does: the elements describing the +/// removed text lose their descriptions, and a reference into a rewritten Form points at the copy. +#[test] +fn removing_hidden_text_cleans_the_structure_tree() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = one_page( + b"/P <> BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC /Fm Do", + "/XObject << /Fm 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >>", + b"/P <> BDC BT /F1 10 Tf 3 Tr 10 150 Td (FORMSECRET) Tj ET EMC", + ), + b"<< /Type /StructTreeRoot /K 9 0 R >>".to_vec(), + b"<< /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (LEAK_PAGE_TAG) /K 0 >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (Public) /K 1 >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /Alt (LEAK_FORM_TAG) /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >>".to_vec(), + ], + ); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"StructTreeRoot".to_vec(), Object::Ref(ObjRef::new(8, 0)))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"StructParents".to_vec(), Object::Int(0))).unwrap(); + remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText"), "untouched content keeps its tags"); +} From 822179ccf2334418c8a711b993528da31d0977ed Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:51:06 +0000 Subject: [PATCH 072/142] fix(redact): remove hidden data from every object that owns it Metadata, private application data, associated files and additional actions were removed from the catalog and pages only. The same entries on an image or font (XMP), a Form XObject (PieceInfo), a page or annotation (/AF with its embedded file) or a form field (/AA scripts) survived the full rewrite, and the scan did not count them, so removing only such a category reported nothing to remove. A walk of every object reachable from the catalog now counts and strips these entries wherever they are, leaving name maps such as resource dictionaries alone (their keys are names a producer chose), and refuses a graph nested too deeply to walk in full. --- crates/redact/src/lib.rs | 4 + crates/redact/src/sanitize.rs | 193 +++++++++++++++++++++++++++++----- crates/redact/src/tests.rs | 94 +++++++++++++++++ 3 files changed, 265 insertions(+), 26 deletions(-) diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 364f7cc19..968773b91 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -64,6 +64,10 @@ pub enum RedactError { "the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused" )] Tags, + /// Objects written in place nest deeper than hidden information is followed, so its removal + /// could not be shown complete. + #[error("the document's objects nest too deeply to remove hidden information completely; the operation was refused")] + TooDeep, #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index fea044951..d9edfbde9 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -15,7 +15,7 @@ use crate::{RedactError, Report, annots_of, listed_annotations, page_streams}; pub enum Hidden { /// Document information (`/Info`) and XMP metadata streams. Metadata, - /// Embedded files and file attachment annotations. + /// Embedded files, associated files (`/AF`) and file attachment annotations. Attachments, /// Comments and markup annotations (with their pop-ups). Comments, @@ -163,6 +163,148 @@ fn outline_len_seen( n } +/// Entries that hold a category's data wherever they appear, not only on the catalog and pages: +/// XMP on images, fonts and Forms, private data on Forms, associated files on pages and +/// annotations, additional actions on fields. +const OWNED: [(Hidden, &[u8]); 4] = + [(Hidden::Metadata, b"Metadata"), (Hidden::PrivateData, b"PieceInfo"), (Hidden::Attachments, b"AF"), (Hidden::LinksActionsScripts, b"AA")]; + +/// Keys whose dictionary values map names a producer chooses to objects (resources, glyph +/// procedures, named destinations, role and class maps, private data by application). A name +/// there may be spelt like an owned entry (a font called `/AF`), so these maps are walked but +/// never stripped. +const NAME_MAPS: [&[u8]; 14] = [ + b"Font", + b"XObject", + b"ExtGState", + b"ColorSpace", + b"Pattern", + b"Shading", + b"Properties", + b"CharProcs", + b"Dests", + b"RoleMap", + b"ClassMap", + b"Colorants", + b"Schema", + b"PieceInfo", +]; + +/// How deep the owner sweep follows dictionaries and arrays written in place. +const MAX_DIRECT_DEPTH: usize = 64; + +/// What a dictionary is to the owner sweep. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Held { + /// An object that may own an entry of [`OWNED`]. + Owner, + /// A [`NAME_MAPS`] map: its keys are names, its values owners. + Map, + /// An appearance dictionary (`/AP`): its dictionary values map appearance states. + Appearances, +} + +impl Held { + fn child(self, key: &[u8]) -> Held { + match self { + Held::Owner if NAME_MAPS.contains(&key) => Held::Map, + Held::Owner if key == b"AP" => Held::Appearances, + Held::Appearances => Held::Map, + _ => Held::Owner, + } + } +} + +/// The objects reachable from the catalog that hold one of `keys` outside a name map, and how +/// many dictionaries hold each key. Each object is read at most once per [`Held`] role, so a +/// shared or cyclic graph costs one step per object. +fn owners(doc: &Document, keys: &[&[u8]]) -> Result<(Vec, Vec<(ObjRef, Held)>), RedactError> { + let mut held = vec![0; keys.len()]; + let mut holders = Vec::new(); + let Some(root) = doc.root() else { return Ok((held, holders)) }; + let mut next = vec![(root, Held::Owner)]; + let mut seen = std::collections::HashSet::new(); + while let Some((r, role)) = next.pop() { + if !seen.insert((r, role)) { + continue; + } + if find_owned(&doc.get(r), role, keys, &mut held, &mut next, 0)? { + holders.push((r, role)); + } + } + Ok((held, holders)) +} + +/// Counts the dictionaries in `o` (and what it holds in place) that hold one of `keys`, and +/// queues the indirect objects it names. Returns whether any key was found. +fn find_owned(o: &Object, role: Held, keys: &[&[u8]], held: &mut [usize], next: &mut Vec<(ObjRef, Held)>, depth: usize) -> Result { + if depth > MAX_DIRECT_DEPTH { + return Err(RedactError::TooDeep); + } + let (d, role) = match o { + Object::Ref(r) => { + next.push((*r, role)); + return Ok(false); + } + Object::Array(a) => { + let mut found = false; + for v in a { + found |= find_owned(v, Held::Owner, keys, held, next, depth + 1)?; + } + return Ok(found); + } + Object::Dict(d) => (d, role), + // A stream's dictionary is never a name map. + Object::Stream(s) => (&s.dict, Held::Owner), + _ => return Ok(false), + }; + let mut found = false; + if role != Held::Map { + for (i, k) in keys.iter().enumerate() { + if d.contains(k) + && let Some(n) = held.get_mut(i) + { + *n = n.saturating_add(1); + found = true; + } + } + } + for (k, v) in d.iter() { + found |= find_owned(v, role.child(k), keys, held, next, depth + 1)?; + } + Ok(found) +} + +/// Removes `keys` from `o` and from what it holds in place, outside name maps. [`owners`] has +/// already refused anything nested past the depth limit. +fn strip_owned(o: &mut Object, role: Held, keys: &[&[u8]], depth: usize) { + if depth > MAX_DIRECT_DEPTH { + return; + } + let (d, role) = match o { + Object::Array(a) => { + for v in a { + strip_owned(v, Held::Owner, keys, depth + 1); + } + return; + } + Object::Dict(d) => (d, role), + Object::Stream(s) => (&mut s.dict, Held::Owner), + _ => return, + }; + if role != Held::Map { + for k in keys { + d.remove(k); + } + } + let names: Vec> = d.iter().filter(|(_, v)| matches!(v, Object::Array(_) | Object::Dict(_))).map(|(k, _)| k.clone()).collect(); + for k in names { + if let Some(v) = d.get_mut(&k) { + strip_owned(v, role.child(&k), keys, depth + 1); + } + } +} + /// A comment as the comments list sees it, less file attachments (removed with Attachments) and /// redaction marks. fn is_comment(subtype: &[u8]) -> bool { @@ -296,6 +438,11 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { let annots: Vec = pages.iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| doc.resolve(&a).as_dict().cloned()).collect(); let count_sub = |s: &[u8]| annots.iter().filter(|a| a.name(b"Subtype") == Some(s)).count(); let names = sub(doc, &cat, b"Names").unwrap_or_default(); + let owned_keys: Vec<&[u8]> = OWNED.iter().map(|o| o.1).collect(); + // A graph nested too deeply to walk counts as holding everything, so the removal runs and + // refuses it. + let held = owners(doc, &owned_keys).map_or_else(|_| vec![1; OWNED.len()], |o| o.0); + let owned = |h: Hidden| OWNED.iter().position(|o| o.0 == h).and_then(|i| held.get(i)).copied().unwrap_or(0); let mut doc2 = doc.clone(); HIDDEN .into_iter() @@ -303,10 +450,13 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { let n = match h { Hidden::Metadata => { let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().map(|d| d.len())).unwrap_or(0); - let xmp = usize::from(cat.contains(b"Metadata")) + pages.iter().filter(|p| p.dict.contains(b"Metadata")).count(); - info + xmp + info + owned(Hidden::Metadata) + } + Hidden::Attachments => { + sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + + count_sub(b"FileAttachment") + + owned(Hidden::Attachments) } - Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + count_sub(b"FileAttachment"), Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), Hidden::FormFields => pdfcraft_forms::fields(doc).len(), Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), @@ -318,12 +468,11 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { Hidden::LinksActionsScripts => { count_sub(b"Link") + usize::from(cat.get(b"OpenAction").is_some_and(|a| doc.resolve(a).as_dict().is_some())) - + usize::from(cat.contains(b"AA")) - + pages.iter().filter(|p| p.dict.contains(b"AA")).count() - + annots.iter().filter(|a| a.contains(b"AA") || (a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A"))).count() + + owned(Hidden::LinksActionsScripts) + + annots.iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count() + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) } - Hidden::PrivateData => usize::from(cat.contains(b"PieceInfo")) + pages.iter().filter(|p| p.dict.contains(b"PieceInfo")).count(), + Hidden::PrivateData => owned(Hidden::PrivateData), }; (h, n) }) @@ -406,24 +555,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result Result { - cat.remove(b"Metadata"); doc.trailer_mut().remove(b"Info"); } Hidden::Attachments => { if let Some(n) = names.as_mut() { n.remove(b"EmbeddedFiles"); } - cat.remove(b"AF"); } Hidden::Bookmarks => { cat.remove(b"Outlines"); @@ -457,14 +592,10 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result { - cat.remove(b"PieceInfo"); - } Hidden::HiddenLayers if !off.is_empty() => { // The off layers leave the configuration (their content is gone already). if let Some(mut oc) = sub(doc, &cat, b"OCProperties") { @@ -494,6 +625,16 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = OWNED.iter().filter(|o| which.contains(&o.0)).map(|o| o.1).collect(); + if !keys.is_empty() { + for (r, role) in owners(doc, &keys)?.1 { + let mut o = (*doc.get(r)).clone(); + strip_owned(&mut o, role, &keys, 0); + doc.set(r, o); + } + } if done.is_empty() { return Err(RedactError::NothingToApply); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 5c0eece85..603fede2c 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2577,3 +2577,97 @@ fn removing_hidden_text_cleans_the_structure_tree() { assert_no_secret(&doc, b"SECRET"); assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText"), "untouched content keeps its tags"); } + +/// One page drawing image /Im (object 7) and form /Fm (object 8), with `extra` objects from 9 on. +fn owners_fixture(image: &str, form: &str, extra: Vec>) -> Document { + let mut objs = vec![ + stream(&format!("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 {image}"), b"\x80"), + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 10 10] {form}"), b"0 0 5 5 re f"), + ]; + objs.extend(extra); + one_page(b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET q 10 0 0 10 0 0 cm /Im Do Q /Fm Do", "/XObject << /Im 7 0 R /Fm 8 0 R >>", objs) +} + +/// Removing one category and saving leaves none of `secret` in the file, and the scan counted it. +fn removes_from_every_owner(mut doc: Document, h: crate::sanitize::Hidden, secret: &[u8]) { + use crate::sanitize::{remove_hidden, scan}; + let counts: std::collections::HashMap<_, usize> = scan(&doc).into_iter().collect(); + assert!(counts[&h] > 0, "{h:?} is counted"); + remove_hidden(&mut doc, &[h]).unwrap(); + assert_no_secret(&doc, secret); + assert!(scan(&reopen(&doc)).into_iter().all(|(k, n)| k != h || n == 0 || h == crate::sanitize::Hidden::Metadata)); +} + +#[test] +fn private_data_of_a_form_xobject_is_removed() { + let doc = owners_fixture("", "/PieceInfo << /App << /Private (LEAK_PRIVATE) >> >>", vec![]); + removes_from_every_owner(doc, crate::sanitize::Hidden::PrivateData, b"LEAK_"); +} + +#[test] +fn metadata_of_an_image_is_removed() { + let doc = owners_fixture("/Metadata 9 0 R", "", vec![stream("/Type /Metadata /Subtype /XML", b"LEAK_XMP")]); + removes_from_every_owner(doc, crate::sanitize::Hidden::Metadata, b"LEAK_"); +} + +#[test] +fn a_pages_associated_files_are_removed_with_attachments() { + let mut doc = owners_fixture( + "", + "", + vec![b"<< /Type /Filespec /F (a.txt) /AFRelationship /Data /EF << /F 10 0 R >> >>".to_vec(), stream("/Type /EmbeddedFile", b"LEAK_ATTACHED")], + ); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"AF".to_vec(), ref_array(&[9]))).unwrap(); + removes_from_every_owner(doc, crate::sanitize::Hidden::Attachments, b"LEAK_"); +} + +#[test] +fn a_fields_own_actions_are_removed_with_links_actions_and_scripts() { + let mut doc = owners_fixture( + "", + "", + vec![ + b"<< /FT /Tx /T (total) /AA << /C << /S /JavaScript /JS (LEAK_SCRIPT) >> >> /Kids [10 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [10 200 150 220] >>".to_vec(), + ], + ); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[9]))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[10]))).unwrap(); + removes_from_every_owner(doc, crate::sanitize::Hidden::LinksActionsScripts, b"LEAK_"); +} + +/// Resource names are chosen by the producer: a font named like an owned entry stays. +#[test] +fn a_resource_named_like_an_owned_entry_is_kept() { + let mut doc = one_page( + b"BT /AF 10 Tf 10 100 Td (Visible) Tj ET", + "", + vec![b"<< /Type /Filespec /F (a.txt) /EF << /F 8 0 R >> >>".to_vec(), stream("/Type /EmbeddedFile", b"LEAK_ATTACHED")], + ); + doc.update_dict(ObjRef::new(3, 0), |d| { + let mut fonts = Dict::new(); + fonts.set(b"AF".to_vec(), Object::Ref(ObjRef::new(5, 0))); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + d.set(b"AF".to_vec(), ref_array(&[7])); + }) + .unwrap(); + removes_from_every_owner(doc.clone(), crate::sanitize::Hidden::Attachments, b"LEAK_"); + crate::sanitize::remove_hidden(&mut doc, &[crate::sanitize::Hidden::Attachments]).unwrap(); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + assert!(res.get(b"Font").and_then(|f| f.as_dict()).is_some_and(|f| f.contains(b"AF"))); + assert!(!page.dict.contains(b"AF")); +} + +/// Owners written in place deeper than the sweep follows are refused, not skipped. +#[test] +fn owners_nested_too_deeply_are_refused() { + use crate::sanitize::{Hidden, remove_hidden}; + let nested = format!("{}<< /AA << /O << /S /JavaScript /JS (LEAK_DEEP) >> >> >>{}", "[".repeat(100), "]".repeat(100)); + let mut doc = one_page(b"BT ET", "", vec![format!("<< /Deep {nested} >>").into_bytes()]); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"PieceInfo".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap(); + let out = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]); + assert!(matches!(out, Err(RedactError::TooDeep)), "{out:?}"); +} From 94563649e604acbd3fb2a8c3cca0185b63dd2493 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:22:48 +0000 Subject: [PATCH 073/142] fix(redact): name the owner sweep's holder list for clippy --- crates/redact/src/sanitize.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index d9edfbde9..7349c433a 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -215,10 +215,13 @@ impl Held { } } +/// Indirect objects that hold an owned entry, each with the role it was reached in. +type Holders = Vec<(ObjRef, Held)>; + /// The objects reachable from the catalog that hold one of `keys` outside a name map, and how /// many dictionaries hold each key. Each object is read at most once per [`Held`] role, so a /// shared or cyclic graph costs one step per object. -fn owners(doc: &Document, keys: &[&[u8]]) -> Result<(Vec, Vec<(ObjRef, Held)>), RedactError> { +fn owners(doc: &Document, keys: &[&[u8]]) -> Result<(Vec, Holders), RedactError> { let mut held = vec![0; keys.len()]; let mut holders = Vec::new(); let Some(root) = doc.root() else { return Ok((held, holders)) }; From 4d64ca8e2da819dff1d0e08c63939560d3b62a78 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:23:26 +0000 Subject: [PATCH 074/142] docs(redact): say which objects hidden-information removal reaches --- crates/redact/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index 9c2793e24..2caf067b5 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -71,7 +71,11 @@ sanitize::sanitize(&mut doc)?; // all of them Categories: metadata (`/Info`, XMP), file attachments, comments, form fields (flattened so the values stay visible), hidden text (render modes 3 and 7, or wholly off the page), hidden layers (content of off optional-content groups, then the groups), bookmarks, links/actions/JavaScript, -and `/PieceInfo` private data. Both make the next save a full rewrite. +and `/PieceInfo` private data. XMP metadata, private data, associated files (`/AF`) and +additional actions (`/AA`) are removed from every object reachable from the catalog that owns +them (images, fonts, Forms, annotations and fields as well as the catalog and pages), never from +name maps such as resource dictionaries. Content is read strictly: a page whose content can't be +read in full is refused, not cleaned in part. Both make the next save a full rewrite. `patterns` finds phone numbers, e-mail addresses, credit card numbers (Luhn-checked), US Social Security numbers and dates for Search & Redact. From 241c5512d646e9be2964ccba96a69be5ab579bb5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:04:40 +0000 Subject: [PATCH 075/142] fix(redact): charge repeated name-tree kids to the node budget The name-tree count cloned each node's /Kids array and skipped repeated references without charging the budget, so a node listing the same kid many times cost work proportional to every entry while the budget saw one step. The walk now borrows the array, charges every entry it does not descend into, and stops once the budget is spent. --- crates/redact/src/sanitize.rs | 30 ++++++++++++++++++++---------- crates/redact/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 7349c433a..5b2918f01 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -92,28 +92,38 @@ const NAME_TREE_NODE_BUDGET: usize = 100_000; /// Entries of a name tree (`/Names` leaves, `/Kids` recursion). Each indirect node is read once, /// however many `/Kids` arrays list it, so shared or cyclic kids cost one step per node rather -/// than one per path; depth and a total node budget bound the rest. +/// than one per path. Every `/Kids` entry the walk does not descend into (a repeated reference or +/// a non-dictionary) costs a step as well, so a long `/Kids` array of repeats cannot outrun the +/// budget. Depth and a total step budget bound the rest. fn name_tree_len(doc: &Document, node: &Dict) -> usize { let (mut seen, mut visited) = (std::collections::HashSet::new(), 0); name_tree_len_seen(doc, node, 0, &mut seen, &mut visited) } fn name_tree_len_seen(doc: &Document, node: &Dict, depth: usize, seen: &mut std::collections::HashSet, visited: &mut usize) -> usize { - *visited += 1; + *visited = visited.saturating_add(1); if depth > 32 || *visited > NAME_TREE_NODE_BUDGET { return 0; } let leaves = node.get(b"Names").and_then(|n| doc.resolve(n).as_array().map(|a| a.len() / 2)).unwrap_or(0); - let kids = node.get(b"Kids").and_then(|k| doc.resolve(k).as_array().cloned()).unwrap_or_default(); let mut n = leaves; - for k in &kids { - if let Object::Ref(r) = k - && !seen.insert(*r) - { - continue; + let kids = node.get(b"Kids").map(|k| doc.resolve(k)); + for k in kids.as_deref().and_then(Object::as_array).map_or(&[][..], Vec::as_slice) { + if *visited > NAME_TREE_NODE_BUDGET { + break; } - if let Some(kid) = doc.resolve(k).as_dict() { - n = n.saturating_add(name_tree_len_seen(doc, kid, depth + 1, seen, visited)); + let held; + let kid = match k { + Object::Ref(r) if !seen.insert(*r) => None, + Object::Ref(r) => { + held = doc.get(*r); + held.as_dict() + } + other => other.as_dict(), + }; + match kid { + Some(kid) => n = n.saturating_add(name_tree_len_seen(doc, kid, depth + 1, seen, visited)), + None => *visited = visited.saturating_add(1), } } n diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 603fede2c..a9902495d 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2671,3 +2671,23 @@ fn owners_nested_too_deeply_are_refused() { let out = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]); assert!(matches!(out, Err(RedactError::TooDeep)), "{out:?}"); } + +/// A name tree node that lists the same kid more times than the node budget: each repeat costs a +/// step, so the walk stops at the budget instead of scanning every repeat, and the distinct kid +/// listed after them is never reached. +#[test] +fn repeated_name_tree_kids_count_towards_the_node_budget() { + use crate::sanitize::{Hidden, scan}; + let repeats = "6 0 R ".repeat(100_010); + let doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 5 0 R >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b"BT ET"), + format!("<< /Kids [{repeats}7 0 R] >>").into_bytes(), + b"<< /Names [(a.txt) << /Type /Filespec /F (a.txt) >>] >>".to_vec(), + b"<< /Names [(b.txt) << /Type /Filespec /F (b.txt) >>] >>".to_vec(), + ]); + let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); + assert_eq!(counts[&Hidden::Attachments], 1, "the walk went past its node budget to the last kid"); +} From a374d16515b9aeec2dc6dbc2ba9dac1a648b02f0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:30:00 +0000 Subject: [PATCH 076/142] fix(cos): index the keys of a large dictionary Dict::set searched every existing entry before inserting a key, so a parsed dictionary of n distinct keys cost n^2/2 key comparisons: a few megabytes of keys stalled parsing for minutes. From 32 entries a dictionary keeps a key index, so each insert and lookup is one hash probe. Equality and debug output still compare and show the entries alone. --- crates/cos/src/object.rs | 87 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 438b8d5f3..444fa3751 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -76,10 +76,35 @@ impl PdfString { /// A name object (without the leading `/`, with `#xx` escapes already decoded). pub type Name = Vec; +#[cfg(test)] +thread_local! { + /// Key comparisons and index lookups made by [`Dict`] on this thread. + static KEY_COMPARES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Entries from which a [`Dict`] keeps a key index. Smaller dictionaries (nearly all of them) +/// search their entries; a parsed file controls the key count, and a linear search per +/// inserted key would make a dictionary of many distinct keys quadratic to build. +const INDEX_FROM: usize = 32; + /// An ordered dictionary. -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Default)] pub struct Dict { entries: Vec<(Name, Object)>, + /// Key to position in `entries`, kept once the dictionary reaches [`INDEX_FROM`] entries. + index: Option>>, +} + +impl PartialEq for Dict { + fn eq(&self, other: &Self) -> bool { + self.entries == other.entries + } +} + +impl std::fmt::Debug for Dict { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Dict").field("entries", &self.entries).finish() + } } impl Dict { @@ -87,26 +112,53 @@ impl Dict { Self::default() } + fn position(&self, key: &[u8]) -> Option { + if let Some(index) = &self.index { + #[cfg(test)] + KEY_COMPARES.with(|c| c.set(c.get() + 1)); + return index.get(key).copied(); + } + self.entries.iter().position(|(k, _)| { + #[cfg(test)] + KEY_COMPARES.with(|c| c.set(c.get() + 1)); + k == key + }) + } + pub fn get(&self, key: &[u8]) -> Option<&Object> { - self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v) + self.position(key).and_then(|i| self.entries.get(i)).map(|(_, v)| v) } pub fn get_mut(&mut self, key: &[u8]) -> Option<&mut Object> { - self.entries.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v) + let i = self.position(key)?; + self.entries.get_mut(i).map(|(_, v)| v) } /// Insert or replace, keeping the position of an existing key. pub fn set(&mut self, key: impl Into, value: impl Into) { let key = key.into(); let value = value.into(); - match self.entries.iter_mut().find(|(k, _)| *k == key) { + match self.position(&key).and_then(|i| self.entries.get_mut(i)) { Some((_, v)) => *v = value, - None => self.entries.push((key, value)), + None => { + let at = self.entries.len(); + if let Some(index) = &mut self.index { + index.insert(key.clone(), at); + } else if at + 1 >= INDEX_FROM { + let index = self.entries.iter().enumerate().map(|(i, (k, _))| (k.clone(), i)).chain([(key.clone(), at)]).collect(); + self.index = Some(Box::new(index)); + } + self.entries.push((key, value)); + } } } pub fn remove(&mut self, key: &[u8]) -> Option { - let i = self.entries.iter().position(|(k, _)| k == key)?; + let i = self.position(key)?; + if let Some(index) = &mut self.index { + index.remove(key); + index.values_mut().filter(|at| **at > i).for_each(|at| *at -= 1); + } Some(self.entries.remove(i).1) } @@ -382,6 +434,29 @@ impl From for Object { mod tests { use super::*; + #[test] + fn parsing_many_distinct_keys_is_not_quadratic() { + let n = 20_000; + let body: String = (0..n).map(|i| format!("/K{i} {i} ")).collect(); + let text = format!("<< {body}/K7 true >>"); + KEY_COMPARES.with(|c| c.set(0)); + let Ok(Object::Dict(d)) = crate::parser::Lexer::new(text.as_bytes(), 0).object() else { panic!("not a dict") }; + let compares = KEY_COMPARES.with(|c| c.get()); + assert!(compares < 8 * n, "{compares} key comparisons for {n} keys"); + // Order and replace-in-place semantics stay those of a plain ordered dictionary. + assert_eq!(d.len(), n); + assert_eq!(d.iter().next().map(|(k, _)| k.as_slice()), Some(&b"K0"[..])); + assert_eq!(d.get(b"K7"), Some(&Object::Bool(true))); + assert_eq!(d.iter().nth(7).map(|(_, v)| v), Some(&Object::Bool(true))); + let mut d = d; + assert_eq!(d.remove(b"K3"), Some(Object::Int(3))); + assert_eq!(d.get(b"K4"), Some(&Object::Int(4))); + assert_eq!(d.iter().nth(3).map(|(k, _)| k.as_slice()), Some(&b"K4"[..])); + d.set(b"K3".to_vec(), Object::Null); + assert_eq!(d.iter().last().map(|(k, _)| k.as_slice()), Some(&b"K3"[..])); + assert!(d.get(b"K20000").is_none()); + } + #[test] fn text_strings_round_trip() { for s in ["Plain ASCII", "Café — naïve", "日本語", "emoji 📄"] { From 58dc10c227e27a5bb5873c12894c8cf8ed61e76f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:33:20 +0000 Subject: [PATCH 077/142] fix(cos): read a damaged real's numeric prefix in one pass To read the longest valid prefix of a token like 1.2.3 the parser retried the conversion on each shorter prefix, so a long digit run followed by long junk was converted once per junk byte: quadratic in the token. The prefix is found in one scan and converted once. A prefix that spells infinity or NaN no longer reads as a number. --- crates/cos/src/parser.rs | 68 +++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 045805596..a16ff10c3 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -324,18 +324,8 @@ impl<'a> Lexer<'a> { if s.contains('.') { // Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix. let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1); - let mut end = cleaned.len(); - while end > 0 { - // Only cut at character boundaries: a token can hold multi-byte UTF-8 (fuzzing - // found ".—", which panicked here). - if cleaned.is_char_boundary(end) - && let Ok(v) = cleaned[..end].parse::() - { - return Ok(Object::Real(v)); - } - end -= 1; - } - return Ok(Object::Real(0.0)); + let v = cleaned.get(..real_prefix_len(cleaned.as_bytes())).and_then(|p| parse_counted::(p).ok()); + return Ok(Object::Real(v.unwrap_or(0.0))); } // Tolerate a doubled sign ("--280") like the real branch does. let t = s.trim_start_matches('+'); @@ -350,6 +340,44 @@ impl<'a> Lexer<'a> { } } +/// Length of the longest prefix of `s` that reads as a decimal number: a sign, digits with at +/// most one `.`, and an exponent with digits. Zero when that prefix has no digit. Found in one +/// pass so a long damaged token is converted once, not once per shorter prefix. +fn real_prefix_len(s: &[u8]) -> usize { + let digits = |from: usize| s.get(from..).map_or(0, |r| r.iter().take_while(|b| b.is_ascii_digit()).count()); + let mut at = usize::from(matches!(s.first(), Some(b'+' | b'-'))); + let int = digits(at); + at += int; + let mut frac = 0; + if s.get(at) == Some(&b'.') { + frac = digits(at + 1); + at += 1 + frac; + } + if int + frac == 0 { + return 0; + } + if matches!(s.get(at), Some(b'e' | b'E')) { + let sign = usize::from(matches!(s.get(at + 1), Some(b'+' | b'-'))); + let exp = digits(at + 1 + sign); + if exp > 0 { + at += 1 + sign + exp; + } + } + at +} + +#[cfg(test)] +thread_local! { + /// Bytes handed to number conversion on this thread. + static NUMBER_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn parse_counted(s: &str) -> Result { + #[cfg(test)] + NUMBER_BYTES.with(|c| c.set(c.get() + s.len())); + s.parse() +} + fn hexval(b: u8) -> Option { match b { b'0'..=b'9' => Some(b - b'0'), @@ -483,6 +511,22 @@ mod tests { assert_eq!(Lexer::new("1.5\u{2014}".as_bytes(), 0).object().unwrap(), Object::Real(1.5)); } + #[test] + fn a_damaged_real_is_converted_once() { + let token = format!("1.{}{}", "5".repeat(2_000), "x".repeat(20_000)); + NUMBER_BYTES.with(|c| c.set(0)); + let Ok(Object::Real(v)) = Lexer::new(token.as_bytes(), 0).object() else { panic!("not a real") }; + let converted = NUMBER_BYTES.with(|c| c.get()); + assert!(converted <= token.len(), "{converted} bytes converted for a {}-byte token", token.len()); + assert!((v - 1.5555).abs() < 1e-3); + // The longest numeric prefix wins, as before. + for (t, want) in + [("1.2.3", 1.2), ("--5.", -5.0), ("+.5x", 0.5), ("5.-", 5.0), ("1.5e3", 1500.0), ("1.5e+", 1.5), ("-.x", 0.0), (".", 0.0), ("-inf.", 0.0)] + { + assert_eq!(Lexer::new(t.as_bytes(), 0).object().ok(), Some(Object::Real(want)), "{t}"); + } + } + fn obj(s: &str) -> Object { Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}")) } From 3c64f9314ef2b6d749dfcfb4abe9652e226208b3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:34:53 +0000 Subject: [PATCH 078/142] fix(content): skip a long unreadable number whole When an operand failed to parse, the content parser went back to the byte after its start and tried again, so a run of n bytes that never reads as a number (a long run of '+', or digits ending in a letter) was read again from every one of its bytes: n^2/2 bytes from a tiny compressed stream. A failed number token longer than 64 bytes is now skipped whole; shorter ones still recover byte by byte. --- crates/content/src/lib.rs | 19 ++++++++++++++++--- crates/content/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index eb126ffa2..04bc1cfa9 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -74,6 +74,9 @@ fn is_delim(b: u8) -> bool { matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') } +/// Longest unreadable number token retried from its next byte. Real numbers are far shorter. +const MAX_RETRIED_TOKEN: usize = 64; + /// Parse a (decoded) content stream. pub fn parse(data: &[u8]) -> Parsed { parse_pieces(data, &[]) @@ -110,9 +113,19 @@ fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed { lx.pos = data.len(); } Err(_) => { - // Skip the offending byte and carry on. - lx.pos = at + 1; - out.skipped += 1; + let read = lx.pos.saturating_sub(at); + #[cfg(test)] + tests::RESCANNED.with(|c| c.set(c.get() + read)); + let number = matches!(b, b'+' | b'-' | b'.') || b.is_ascii_digit(); + if number && read > MAX_RETRIED_TOKEN { + // A long unreadable number is skipped whole: retrying from each of its + // bytes would read the rest of it again every time. + out.skipped += read; + } else { + // Skip the offending byte and carry on. + lx.pos = at + 1; + out.skipped += 1; + } } } continue; diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 094a0665d..f32ab3da5 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -1,5 +1,27 @@ use super::*; +thread_local! { + /// Bytes the lexer read before an operand failed, on this thread. + pub(crate) static RESCANNED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[test] +fn a_long_unreadable_number_is_read_once() { + for junk in ["+", "5"] { + let n = 20_000; + let src = format!("{}{} 1 0 0 1 0 0 cm", junk.repeat(n), if junk == "5" { "x" } else { "" }); + RESCANNED.with(|c| c.set(0)); + let p = parse(src.as_bytes()); + let rescanned = RESCANNED.with(|c| c.get()); + assert!(rescanned <= 2 * n, "{rescanned} bytes read for a {n}-byte token"); + assert_eq!(p.ops.last().and_then(|o| o.nums::<6>()), Some([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + } + // A short damaged number still recovers byte by byte. + let p = parse(b"-+5 0 Td"); + assert_eq!((p.ops.len(), p.skipped), (1, 1)); + assert_eq!(p.ops[0].nums::<2>(), Some([5.0, 0.0])); +} + #[test] fn operators_keep_operands_and_spans() { let src = b"q 1 0 0 1 72 720 cm BT /F1 12 Tf (Hello \\(world\\)) Tj [(A) -120 (B)] TJ ET Q"; From 07aea983d0b5df84b474f28d374b143254e5c41f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:35:25 +0000 Subject: [PATCH 079/142] fix(content): splice copies only the pieces a range touches Pieces::splice copied the unedited bytes between edits by visiting every piece for every edit, so a page of many content pieces and many replaced operators cost pieces x edits steps. The copy starts at the piece holding its first byte and stops past its last one. --- crates/content/src/lib.rs | 13 ++++++++++++- crates/content/src/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index 04bc1cfa9..986bab495 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -504,9 +504,20 @@ impl Pieces { /// for byte. Returns every piece's new bytes (unchanged pieces come back equal). pub fn splice(&self, edits: impl IntoIterator, Vec)>) -> Vec> { let mut out: Vec> = (0..self.len()).map(|i| Vec::with_capacity(self.range(i).len())).collect(); + // Only the pieces from the one holding `from` up to `to`: one call per edit must not + // visit every piece. let copy = |out: &mut Vec>, from: usize, to: usize| { - for (i, piece) in out.iter_mut().enumerate() { + if from >= to { + return; + } + let first = self.piece_of(from); + for (i, piece) in out.iter_mut().enumerate().skip(first) { + #[cfg(test)] + tests::PIECE_VISITS.with(|c| c.set(c.get() + 1)); let r = self.range(i); + if r.start >= to { + break; + } let (a, b) = (from.max(r.start), to.min(r.end)); if a < b { piece.extend_from_slice(self.data.get(a..b).unwrap_or_default()); diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index f32ab3da5..acf494320 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -5,6 +5,30 @@ thread_local! { pub(crate) static RESCANNED: std::cell::Cell = const { std::cell::Cell::new(0) }; } +thread_local! { + /// Pieces [`Pieces::splice`] looked at while copying, on this thread. + pub(crate) static PIECE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[test] +fn splicing_visits_each_piece_a_few_times() { + let n = 5_000; + let pieces: Vec = (0..n).map(|i| format!("({i}) Tj")).collect(); + let joined = Pieces::join(&pieces); + let parsed = joined.parse(); + let edits: Vec<_> = parsed.ops.iter().map(|op| (op.span.clone(), b"(x) Tj".to_vec())).collect(); + assert_eq!(edits.len(), n); + PIECE_VISITS.with(|c| c.set(0)); + let out = joined.splice(edits); + let visits = PIECE_VISITS.with(|c| c.get()); + assert!(visits <= 4 * n, "{visits} piece visits for {n} pieces"); + assert!(out.iter().all(|p| p == b"(x) Tj")); + // A replacement spanning two pieces stays whole in the first and leaves the rest of the second. + let two = Pieces::join(&["q (a)", "Tj Q"]); + let tj = two.parse().ops.into_iter().find(|o| o.is("Tj")).map(|o| o.span).unwrap_or_default(); + assert_eq!(two.splice([(tj, b"(b) Tj".to_vec())]), [b"q (b) Tj".to_vec(), b" Q".to_vec()]); +} + #[test] fn a_long_unreadable_number_is_read_once() { for junk in ["+", "5"] { From e2c71b60e5e1f5f8aad7da8cbabcdf1626dbe89c Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:36:19 +0000 Subject: [PATCH 080/142] fix(filters): refuse a filter chain longer than eight Every stage of a stream's filter chain could produce up to the output limit and nothing bounded the number of stages, so a long /Filter array over data that decodes to itself (FF FF under RunLengthDecode) repeated the full work once per array entry. Chains of more than eight filters are unsupported, like an image codec, and stay encoded. --- crates/filters/src/lib.rs | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 9a0124d85..87306166c 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -137,8 +137,8 @@ pub(crate) type Step = Result, Failure>; /// Decodes `data` through `chain` (first element applied first). /// /// Image codecs, `Crypt` and unknown filters anywhere in the chain give -/// [`FilterError::Unsupported`] (callers keep such data encoded). `max_output` bounds -/// every intermediate buffer. +/// [`FilterError::Unsupported`] (callers keep such data encoded), as does a chain longer than +/// [`MAX_CHAIN`]. `max_output` bounds every intermediate buffer. pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result, FilterError> { run(chain, data, max_output, true).map(|(v, _)| v) } @@ -150,13 +150,22 @@ pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usiz run(chain, data, max_output, false) } +/// Longest filter chain decoded. Real streams use one to three filters; every stage may produce +/// up to the output limit, so the chain length multiplies the work. +pub const MAX_CHAIN: usize = 8; + fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool) -> Result<(Vec, bool), FilterError> { + if chain.len() > MAX_CHAIN { + return Err(FilterError::Unsupported(format!("a chain of {} filters", chain.len()))); + } if let Some((f, _)) = chain.iter().find(|(f, _)| !f.is_decodable()) { return Err(FilterError::Unsupported(f.name().to_owned())); } let mut cur: Cow<[u8]> = Cow::Borrowed(data); let mut partial = false; for (filter, params) in chain { + #[cfg(test)] + STAGE_BYTES.with(|c| c.set(c.get() + cur.len())); cur = Cow::Owned(match decode_one(filter, params, &cur, max) { Ok(v) => v, Err(Failure { error: FilterError::Corrupt { .. }, partial: out }) if !strict => { @@ -218,8 +227,27 @@ pub(crate) fn is_pdf_whitespace(b: u8) -> bool { matches!(b, 0 | 9 | 10 | 12 | 13 | 32) } +#[cfg(test)] +thread_local! { + /// Bytes handed to decoding stages on this thread. + static STAGE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + #[cfg(test)] mod tests { + #[test] + fn a_long_filter_chain_is_refused_before_decoding() { + // `FF FF` run-length decodes to itself, so every stage of the chain repeats the work. + let data = vec![0xFF; 4096]; + let chain = |n: usize| vec![(super::Filter::RunLength, super::Params::default()); n]; + super::STAGE_BYTES.with(|c| c.set(0)); + let long = super::decode(&chain(1000), &data, 1 << 20); + let read = super::STAGE_BYTES.with(|c| c.get()); + assert!(read <= 16 * data.len(), "{read} bytes decoded through the chain"); + assert!(matches!(long, Err(super::FilterError::Unsupported(_))), "{long:?}"); + assert_eq!(super::decode(&chain(super::MAX_CHAIN), &data, 1 << 20).ok(), Some(data)); + } + use super::*; #[test] From 92e713feb1ac2dac75b26bdc2d2952ed44d294ee Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:37:09 +0000 Subject: [PATCH 081/142] fix(cos): a /Filter that is not a name does not decode as unfiltered Stream::filters reads only a direct name or array of names, so an indirect /Filter (or an array holding a reference) gave an empty chain and every decode returned the encoded bytes as if they were the data: a strict decode reported success on content it never decoded, while a viewer resolving the reference shows something else. Decoding such a stream is an error, so callers keep it encoded or refuse it. --- crates/cos/src/object.rs | 45 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 444fa3751..6b6a4befe 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -256,6 +256,21 @@ impl Stream { .collect() } + /// [`Stream::filters`], or an error when `/Filter` is present but is not a name or an + /// array of names (an indirect reference, say): the data is not unfiltered, so it must not + /// read as decoded. + fn readable_filters(&self) -> Result, CosError> { + let readable = match self.dict.get(b"Filter") { + None | Some(Object::Null | Object::Name(_)) => true, + Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some()), + Some(_) => false, + }; + if !readable { + return Err(CosError::Filter("/Filter is not a name or an array of names".into())); + } + Ok(self.filters()) + } + /// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data /// decoded before the damage is returned). Streams with image codecs fail with /// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage. @@ -266,7 +281,7 @@ impl Stream { /// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than /// [`MAX_DECODED`]: decoding past `max` bytes is an error. pub fn decoded_within(&self, max: usize) -> Result, CosError> { - let chain = self.filters(); + let chain = self.readable_filters()?; if chain.is_empty() { return self.raw_within(max); } @@ -275,7 +290,7 @@ impl Stream { /// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error. pub fn decoded_strict(&self) -> Result, CosError> { - let chain = self.filters(); + let chain = self.readable_filters()?; if chain.is_empty() { return self.raw_within(MAX_DECODED); } @@ -285,7 +300,7 @@ impl Stream { /// Data after the first filter alone, bounded by `max`: the rest of the chain stays encoded. /// Any corruption in that first filter is an error. pub fn decoded_first_filter_strict(&self, max: usize) -> Result, CosError> { - let mut chain = self.filters(); + let mut chain = self.readable_filters()?; chain.truncate(1); if chain.is_empty() { return self.raw_within(max); @@ -434,6 +449,30 @@ impl From for Object { mod tests { use super::*; + #[test] + fn a_filter_that_is_not_a_direct_name_is_not_decoded_as_absent() { + let stream = |filter: Object| { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), filter); + Stream::from_raw(d, b"48 69>".to_vec()) + }; + let hex = Object::Name(b"ASCIIHexDecode".to_vec()); + assert_eq!(stream(hex.clone()).decoded_strict().ok(), Some(b"Hi".to_vec())); + assert_eq!(stream(Object::Array(vec![hex.clone()])).decoded().ok(), Some(b"Hi".to_vec())); + assert_eq!(stream(Object::Null).decoded_strict().ok(), Some(b"48 69>".to_vec())); + for filter in [ + Object::Ref(ObjRef::new(5, 0)), + Object::Array(vec![Object::Ref(ObjRef::new(5, 0))]), + Object::Int(3), + Object::Array(vec![hex, Object::Int(1)]), + ] { + let s = stream(filter.clone()); + assert!(s.decoded_strict().is_err(), "{filter:?}"); + assert!(s.decoded().is_err(), "{filter:?}"); + assert!(s.decoded_first_filter_strict(1 << 20).is_err(), "{filter:?}"); + } + } + #[test] fn parsing_many_distinct_keys_is_not_quadratic() { let n = 20_000; From 73d15cd8b25c38483bd6be7e9d6c0aa59bb92bf7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:38:04 +0000 Subject: [PATCH 082/142] fix(cos): count strings at every depth against the object caches The object caches weigh what each cached object owns, but the walk stopped four levels down and counted anything deeper as nothing. Many objects holding a large string inside a few nested arrays stayed cached far past the byte budget (34 MiB against 32 MiB in the test, and without limit as objects grow). The walk now goes past the parser's nesting limit; anything deeper counts as too large to keep. --- crates/cos/src/document.rs | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index c3f6c6b74..96975eb36 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -494,12 +494,16 @@ impl ObjectReader { } } +/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object +/// is measured in full. +const OWNED_BYTES_DEPTH: usize = 256; + /// Bytes `object` holds of its own: string and stream data that is not a view into `data` -/// (the input file), nested a few levels deep (deeper data is rare and still bounded by the -/// reader's object count). +/// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured +/// and counts as too large to keep. fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { - if depth > 4 { - return 0; + if depth > OWNED_BYTES_DEPTH { + return usize::MAX; } match object { Object::String(s) => s.bytes.len(), @@ -2993,6 +2997,29 @@ endobj } } + #[test] + fn editor_cache_counts_deeply_nested_strings() { + let mut nested = Object::String(crate::PdfString::literal(vec![b'x'; 1 << 20])); + for _ in 0..8 { + nested = Object::Array(vec![nested]); + } + let mut source = Document::new_empty(); + let objects: Vec<_> = (0..48).map(|_| Object::Ref(source.add(nested.clone()))).collect(); + source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); + let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); + let doc = Document::open(bytes).unwrap(); + assert_eq!(owned_bytes(&nested, &doc.data, 0), 1 << 20); + let root = doc.get(doc.root().unwrap()); + for reference in root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap() { + let Object::Ref(reference) = reference else { panic!("indirect object") }; + drop(doc.get(*reference)); + let cache = doc.cache.lock().unwrap(); + let owned = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0))); + let strings = cache.values().filter(|o| o.as_array().is_some()).count(); + assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted"); + } + } + #[test] fn editor_cache_stays_bounded() { let mut source = Document::new_empty(); From b1224557058aaf568a17f4c5063878d6131c89a2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:39:54 +0000 Subject: [PATCH 083/142] fix(cos): find endstream once per document, not once per stream A stream whose /Length is wrong or missing is ended at the next endstream, which the parser found by searching the rest of the file. Many such streams before one late endstream (a damaged file being reconstructed reads every one) cost the file size per stream. The document lists where endstream occurs the first time it needs it, and each stream looks its end up there. --- crates/cos/src/document.rs | 41 +++++++++++++++++++++++++++++++----- crates/cos/src/parser.rs | 43 ++++++++++++++++++++++++++++++++------ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 96975eb36..b783a8245 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex, Weak}; use crate::CosError; use crate::object::{Dict, ObjRef, Object}; -use crate::parser::{Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; +use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; thread_local! { /// The objects being loaded on this thread, innermost last (see `Document::try_get`). @@ -405,6 +405,8 @@ impl std::fmt::Debug for ReadState { #[derive(Clone)] pub struct Document { data: Arc>, + /// Where `endstream` occurs in `data`; see [`EndstreamIndex`]. + endstreams: Arc, source_identity: Arc<()>, entries: Arc, trailer: Dict, @@ -536,6 +538,7 @@ impl Document { pub fn new_empty() -> Self { let mut doc = Document { data: Arc::new(Vec::new()), + endstreams: Arc::default(), source_identity: Arc::new(()), entries: Arc::new(XrefIndex::empty()), trailer: Dict::new(), @@ -618,6 +621,7 @@ impl Document { }; let mut doc = Document { data, + endstreams: Arc::default(), source_identity: Arc::new(()), entries: Arc::new(XrefIndex::empty()), trailer: Dict::new(), @@ -982,7 +986,7 @@ impl Document { match self.xref_entry(num) { None | Some(XrefEntry::Free { .. }) => true, Some(XrefEntry::InFile { offset, .. }) => { - let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num); + let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None, &self.endstreams), Ok((id, _)) if id.num == num); // Offsets may count from a header that does not start the file. usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset)))) } @@ -1180,7 +1184,7 @@ impl Document { length }; let off = offset as usize; - let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve) { + let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve, &self.endstreams) { Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)), _ => None, }; @@ -1246,7 +1250,7 @@ impl Document { if p > 0 && !is_whitespace(data[p - 1]) { continue; } - if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None) + if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None, &self.endstreams) && id.num == num { found = Some((id, o)); // keep the last (newest) definition @@ -2055,7 +2059,7 @@ impl Document { if trailer.as_ref().is_some_and(|((catalog, p), _)| *catalog && *p > at) { continue; } - if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None) + if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None, &self.endstreams) && s.dict.name(b"Type") == Some(b"XRef") && let Some(r) = rank(at, &s.dict) && trailer.as_ref().is_none_or(|(best, _)| r > *best) @@ -2997,6 +3001,33 @@ endobj } } + #[test] + fn streams_without_length_share_one_endstream_search() { + let n = 2_000; + let mut file = b"%PDF-1.7\n".to_vec(); + for k in 1..=n { + file.extend_from_slice(format!("{k} 0 obj\n<< >>\nstream\nxx\n").as_bytes()); + } + file.extend_from_slice(b"endstream\nendobj\n"); + file.extend_from_slice( + format!( + "{0} 0 obj\n<< /Type /Catalog /Pages {1} 0 R >>\nendobj\n{1} 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n", + n + 1, + n + 2 + ) + .as_bytes(), + ); + file.extend_from_slice(format!("trailer\n<< /Root {} 0 R /Size {} >>\n%%EOF\n", n + 1, n + 3).as_bytes()); + let len = file.len(); + crate::parser::ENDSTREAM_SCAN.with(|c| c.set(0)); + let doc = Document::open(Arc::new(file)).unwrap(); + for k in [1, n / 2, n] { + assert!(matches!(doc.try_get(k).as_deref(), Ok(Object::Stream(_))), "object {k}"); + } + let scanned = crate::parser::ENDSTREAM_SCAN.with(|c| c.get()); + assert!(scanned <= 4 * len, "{scanned} bytes searched in a {len}-byte file"); + } + #[test] fn editor_cache_counts_deeply_nested_strings() { let mut nested = Object::String(crate::PdfString::literal(vec![b'x'; 1 << 20])); diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index a16ff10c3..b470cffa4 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -368,6 +368,8 @@ fn real_prefix_len(s: &[u8]) -> usize { #[cfg(test)] thread_local! { + /// Bytes searched for `endstream` on this thread. + pub(crate) static ENDSTREAM_SCAN: std::cell::Cell = const { std::cell::Cell::new(0) }; /// Bytes handed to number conversion on this thread. static NUMBER_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -390,17 +392,30 @@ fn hexval(b: u8) -> Option { /// Parse an indirect object at `offset`: `N G obj [stream…endstream] endobj`. /// `resolve_length` resolves an indirect `/Length`. Returns the object and its (num, gen). pub fn parse_indirect(data: &[u8], offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option) -> Result<(ObjRef, Object), CosError> { - parse_indirect_in(data, None, offset, resolve_length) + parse_indirect_in(data, None, offset, resolve_length, None) } /// [`parse_indirect`] for a document's own bytes: stream data points into `data` instead of /// being copied out of it. +/// `endstreams` is the document's [`EndstreamIndex`] for `data`. pub(crate) fn parse_indirect_shared( data: &Arc>, offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option, + endstreams: &EndstreamIndex, ) -> Result<(ObjRef, Object), CosError> { - parse_indirect_in(data, Some(data), offset, resolve_length) + parse_indirect_in(data, Some(data), offset, resolve_length, Some(endstreams)) +} + +/// Where `endstream` occurs in a document's bytes, found once, the first time a stream's +/// /Length is wrong or missing. Searching the rest of the file for each such stream instead +/// makes many of them ending at one late `endstream` quadratic to read. +pub(crate) type EndstreamIndex = std::sync::OnceLock>; + +fn endstream_offsets(data: &[u8]) -> Vec { + #[cfg(test)] + ENDSTREAM_SCAN.with(|c| c.set(c.get() + data.len())); + data.windows(9).enumerate().filter(|(_, w)| *w == b"endstream").map(|(i, _)| i).collect() } fn parse_indirect_in( @@ -408,6 +423,7 @@ fn parse_indirect_in( shared: Option<&Arc>>, offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option, + endstreams: Option<&EndstreamIndex>, ) -> Result<(ObjRef, Object), CosError> { let mut lx = Lexer::new(data, offset); lx.skip_ws(); @@ -467,7 +483,11 @@ fn parse_indirect_in( let len = match declared { Some(l) if valid(l) => l as usize, // Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). - _ => find_endstream(data, start).ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? - start, + _ => { + find_endstream(data, start, endstreams) + .ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? + - start + } }; let range = start..start + len; let raw = match shared { @@ -483,9 +503,20 @@ fn parse_indirect_in( } /// Offset where stream data ends (before the EOL preceding "endstream"). -fn find_endstream(data: &[u8], from: usize) -> Option { - let hay = data.get(from..)?; - let at = hay.windows(9).position(|w| w == b"endstream")? + from; +fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) -> Option { + let at = match endstreams { + Some(index) => { + let index = index.get_or_init(|| endstream_offsets(data)); + index.get(index.partition_point(|&p| p < from)).copied()? + } + None => { + let hay = data.get(from..)?; + let found = hay.windows(9).position(|w| w == b"endstream"); + #[cfg(test)] + ENDSTREAM_SCAN.with(|c| c.set(c.get() + found.map_or(hay.len(), |p| p + 9))); + found? + from + } + }; let mut end = at; if end > from && data[end - 1] == b'\n' { end -= 1; From 196be22b64c39bb6315a0399db4392fa404e211e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:41:37 +0000 Subject: [PATCH 084/142] fix(cos): read a hybrid cross-reference stream once per chain The materialized cross-reference reader reread a section's /XRefStm for every revision that named it, decoding the stream and inserting its rows each time, although the first read had already merged every entry. Up to 10,000 revisions naming one large stream repeated that work 10,000 times. A stream already read is skipped, with one repair log line. --- crates/cos/src/document.rs | 42 +++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index b783a8245..470f395cd 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -496,6 +496,12 @@ impl ObjectReader { } } +#[cfg(test)] +thread_local! { + /// Cross-reference sections read on this thread. + static SECTION_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object /// is measured in full. const OWNED_BYTES_DEPTH: usize = 256; @@ -1748,6 +1754,9 @@ impl Document { let mut revisions = Vec::new(); let mut oddities = Vec::new(); let mut visited = HashSet::new(); + // Hybrid streams already read: a newer section has merged every entry of one, so reading + // it again for each older section that names it adds nothing and repeats the decode. + let (mut hybrids, mut noted) = (HashSet::new(), HashSet::new()); let mut next = Some(first); while let Some(off) = next.take() { if !visited.insert(off) || visited.len() > 10_000 { @@ -1778,7 +1787,12 @@ impl Document { revisions.push(Revision { xref_offset: off, is_stream, header_relative }); // Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the // free entries it gives objects only a cross-reference stream can locate. - if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { + let hybrid = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0); + let repeated = hybrid.is_some_and(|x| !hybrids.insert(x)); + if let Some(x) = hybrid.filter(|x| repeated && noted.insert(*x)) { + log.push(format!("hybrid /XRefStm at {x} named again by the section at {off}; read once")); + } + if let Some(x) = hybrid.filter(|_| !repeated) { let mut stm = BTreeMap::new(); match self.read_section(x as usize, &mut stm, &mut odd, log) { Ok(_) => { @@ -1822,6 +1836,8 @@ impl Document { odd: &mut Vec, log: &mut Vec, ) -> Result<(Dict, bool), CosError> { + #[cfg(test)] + SECTION_READS.with(|c| c.set(c.get() + 1)); let data = &self.data; let mut lx = Lexer::new(data, off); if lx.eat_keyword(b"xref") { @@ -3199,6 +3215,30 @@ endobj out } + /// Many revisions naming one hybrid /XRefStm read it once. + #[test] + fn a_hybrid_stream_named_by_many_revisions_is_read_once() { + let mut out = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0); + let stream = find(&out, b"5 0 obj", 0, out.len()).unwrap(); + let mut prev = rfind(&out, b"\nxref\n", 0).unwrap() + 1; + let revisions = 200; + for _ in 0..revisions { + let at = out.len(); + out.extend_from_slice( + format!("xref\ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(), + ); + prev = at; + } + let doc = Document::open(Arc::new(out)).unwrap(); + SECTION_READS.with(|c| c.set(0)); + let (entries, ..) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap(); + let reads = SECTION_READS.with(|c| c.get()); + assert!(reads <= revisions + 2, "{reads} sections read for {} revisions", revisions + 1); + assert!(matches!(entries.get(&3), Some(XrefEntry::InStream { stream: 4, .. })), "{:?}", entries.get(&3)); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes")); + assert_compact_matches_materialized(&doc); + } + fn text_of(doc: &Document, num: u32, key: &[u8]) -> Option { doc.get(ObjRef::new(num, 0)).as_dict().and_then(|d| d.get(key).and_then(|v| v.as_string().map(|s| s.to_text()))) } From 1127a660310853520b21a2c0f98688aaf2afb4d0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:42:43 +0000 Subject: [PATCH 085/142] fix(crypt): resolve crypt filter methods once per handler Every string and stream decrypted searched the document's /CF filters for the method by name. A file controls both the number of filters and the number of strings, so the search multiplied the two when the filter in use was listed last. The handler resolves the string, stream and embedded-file methods when it is built, and looks named filters up in a map. --- crates/crypt/src/lib.rs | 66 +++++++++++++++++++++++++++++---------- crates/crypt/src/tests.rs | 26 +++++++++++++++ 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index 82b598688..58e96e7d4 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -138,9 +138,51 @@ pub struct SecurityHandler { key: Vec, auth: Auth, recovered_user: Option>, + methods: Methods, +} + +/// The crypt filter methods of a handler, resolved once: a document controls both the number of +/// filters and the number of strings, so a search of `/CF` per string would multiply them. +#[derive(Clone, Debug)] +struct Methods { + string: Method, + stream: Method, + embedded_file: Method, + named: std::collections::HashMap, Method>, +} + +impl Methods { + fn new(dict: &EncryptDict) -> Self { + let mut named = std::collections::HashMap::with_capacity(dict.crypt_filters.len()); + for (name, method) in &dict.crypt_filters { + #[cfg(test)] + tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1)); + // The first of two filters with one name wins, as a search would find it. + named.entry(name.clone()).or_insert(*method); + } + let mut methods = Methods { string: Method::Identity, stream: Method::Identity, embedded_file: Method::Identity, named }; + methods.string = methods.get(&dict.str_f); + methods.stream = methods.get(&dict.stm_f); + methods.embedded_file = methods.get(if dict.ef_f.is_empty() { &dict.stm_f } else { &dict.ef_f }); + methods + } + + fn get(&self, name: &[u8]) -> Method { + if name == b"Identity" || name.is_empty() { + return Method::Identity; + } + #[cfg(test)] + tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1)); + self.named.get(name).copied().unwrap_or(Method::Identity) + } } impl SecurityHandler { + fn new(dict: EncryptDict, key: Vec, auth: Auth, recovered_user: Option>) -> Self { + let methods = Methods::new(&dict); + Self { dict, key, auth, recovered_user, methods } + } + /// Authenticate with `password` (tried as owner password, then user password). `None` /// tries the empty password, which opens documents that only have an owner password. pub fn open(dict: EncryptDict, id0: &[u8], password: Option<&str>) -> Result { @@ -177,7 +219,7 @@ impl SecurityHandler { } r => return Err(CryptError::Unsupported(format!("revision {r}"))), }; - Ok(Self { dict, key, auth, recovered_user }) + Ok(Self::new(dict, key, auth, recovered_user)) } /// Upgrade to owner access with the owner (permissions) password, as when a restricted @@ -235,26 +277,16 @@ impl SecurityHandler { self.dict.encrypt_metadata } - fn method_named(&self, name: &[u8]) -> Method { - if name == b"Identity" || name.is_empty() { - return Method::Identity; - } - self.dict.crypt_filters.iter().find(|(n, _)| n == name).map(|(_, m)| *m).unwrap_or(Method::Identity) - } - /// The method for strings or a kind of stream. fn method(&self, stream: Option) -> Method { if self.dict.v < 4 { return Method::Rc4; } match stream { - None => self.method_named(&self.dict.str_f), - Some(StreamKind::Normal) => self.method_named(&self.dict.stm_f), - Some(StreamKind::EmbeddedFile) => { - let eff = if self.dict.ef_f.is_empty() { &self.dict.stm_f } else { &self.dict.ef_f }; - self.method_named(eff) - } - Some(StreamKind::Named(n)) => self.method_named(n), + None => self.methods.string, + Some(StreamKind::Normal) => self.methods.stream, + Some(StreamKind::EmbeddedFile) => self.methods.embedded_file, + Some(StreamKind::Named(n)) => self.methods.get(n), } } @@ -642,7 +674,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result { (d.v, d.r, d.length_bits) = (5, 6, 256); @@ -671,7 +703,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result = const { std::cell::Cell::new(0) }; +} + +#[test] +fn crypt_filters_are_looked_up_once_per_handler() { + let plain = new(Algorithm::Aes128, "", "o", -1); + let mut d = plain.dict().clone(); + let n = 5_000; + let mut filters: Vec<_> = (0..n).map(|i| (format!("F{i}").into_bytes(), Method::Identity)).collect(); + filters.append(&mut d.crypt_filters); + d.crypt_filters = filters; + FILTER_COMPARES.with(|c| c.set(0)); + let h = SecurityHandler::open(d, ID, None).unwrap(); + let strings = 1_000; + let enc = plain.encrypt_string(7, 0, b"secret"); + for _ in 0..strings { + assert_eq!(h.decrypt_string(7, 0, &enc), b"secret"); + assert_eq!(h.decrypt_stream(7, 0, &plain.encrypt_stream(7, 0, b"s", StreamKind::Normal), StreamKind::Normal), b"s"); + } + assert_eq!(h.decrypt_stream(7, 0, b"x", StreamKind::Named(b"F3")), b"x"); + let compares = FILTER_COMPARES.with(|c| c.get()); + assert!(compares <= 2 * n + 3 * strings, "{compares} filter name comparisons"); +} + #[test] fn identity_crypt_filters_leave_data_alone() { let mut d = new(Algorithm::Aes256, "", "o", -1).dict().clone(); From 6905acad2c12e3d7051a2835657c46d5da2b3001 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:44:26 +0000 Subject: [PATCH 086/142] fix(model): bound the inherited values a page walk copies Each leaf page gets its own copy of every attribute it inherits, so a large direct /Resources on the root of a page tree with thousands of leaves was copied thousands of times: a compressed file of a few kilobytes could need tens of gigabytes. The walk weighs each inherited value once where it is declared and charges every copy to a 1 GiB budget; past it the walk is cut off, as an over-large tree already is. --- crates/model/src/lib.rs | 73 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 38bf36f22..886684c6d 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -82,14 +82,36 @@ pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] { } /// Leaf pages in document order, with inherited attributes resolved. Cycles and absurdly deep -/// trees are cut off rather than followed. +/// trees are cut off rather than followed, as is a tree whose inherited values would take more +/// than [`MAX_INHERITED_COPIES`] bytes to copy into its leaves. pub fn pages(doc: &Document) -> Vec { PageTree::new(doc).collect() } +/// Approximate bytes of inherited values copied into leaf dictionaries by one page walk. +pub const MAX_INHERITED_COPIES: usize = 1 << 30; + +/// Approximate heap bytes of `o`: one [`Object`] per node plus string, name and key bytes. +/// Values nested deeper than the parser allows weigh as too large. +fn weight(o: &Object, depth: usize) -> usize { + if depth > 256 { + return usize::MAX; + } + let own = std::mem::size_of::(); + match o { + Object::String(s) => own.saturating_add(s.bytes.len()), + Object::Name(n) => own.saturating_add(n.len()), + Object::Array(a) => a.iter().fold(own, |n, v| n.saturating_add(weight(v, depth + 1))), + Object::Dict(d) => d.iter().fold(own, |n, (k, v)| n.saturating_add(k.len()).saturating_add(weight(v, depth + 1))), + Object::Stream(s) => s.dict.iter().fold(own, |n, (k, v)| n.saturating_add(k.len()).saturating_add(weight(v, depth + 1))), + _ => own, + } +} + // Retain the owner of each inherited value rather than deep-cloning direct Resources on -// every edge. Only the final Page dictionaries need owned copies of those values. -type Inherited = [Option>; 4]; +// every edge. Only the final Page dictionaries need owned copies of those values. Each owner +// carries the value's weight, measured once where the value is declared. +type Inherited = [Option<(std::sync::Arc, usize)>; 4]; struct Children { owner: std::sync::Arc, @@ -112,16 +134,23 @@ struct PageTree<'a> { // One cursor per ancestor, never one entry per child. This also preserves the first // depth-first occurrence's attributes when malformed trees share a child. pending: Vec, + // Weight left for copying inherited values into leaves. A large direct value on an + // ancestor of many leaves would otherwise be copied once per leaf. + budget: usize, } impl<'a> PageTree<'a> { fn new(doc: &'a Document) -> Self { + Self::with_budget(doc, MAX_INHERITED_COPIES) + } + + fn with_budget(doc: &'a Document, budget: usize) -> Self { let top = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))); let pending = top .into_iter() .map(|r| Children { owner: std::sync::Arc::new(Object::Array(vec![Object::Ref(r)])), next: 0, inherited: Default::default() }) .collect(); - Self { doc, seen: Default::default(), pending } + Self { doc, seen: Default::default(), pending, budget } } } @@ -149,8 +178,8 @@ impl Iterator for PageTree<'_> { let Some(d) = obj.as_dict() else { continue }; let mut inherited = parent.inherited.clone(); for (key, owner) in INHERITABLE.iter().zip(&mut inherited) { - if d.contains(key) { - *owner = Some(obj.clone()); + if let Some(value) = d.get(key) { + *owner = Some((obj.clone(), weight(value, 0))); } } let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); @@ -165,8 +194,15 @@ impl Iterator for PageTree<'_> { let mut dict = d.clone(); for (key, owner) in INHERITABLE.iter().zip(&inherited) { if !dict.contains(key) - && let Some(value) = owner.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(key)) + && let Some((owner, weight)) = owner + && let Some(value) = owner.as_dict().and_then(|d| d.get(key)) { + // Past the budget the walk is cut off, like a tree too large to follow. + let Some(left) = self.budget.checked_sub(*weight) else { + self.pending.clear(); + return None; + }; + self.budget = left; dict.set(key.to_vec(), value.clone()); } } @@ -209,6 +245,29 @@ mod tests { Document::open(Arc::new(out)).unwrap() } + #[test] + fn inherited_copies_stop_at_a_budget() { + let mut d = doc(0); + let root = ObjRef { num: 2, generation: 0 }; + let leaf = d.get(ObjRef { num: 3, generation: 0 }).as_dict().unwrap().clone(); + let kids: Vec<_> = (0..200).map(|_| Object::Ref(d.add(leaf.clone()))).collect(); + let mut resources = Dict::new(); + resources.set(b"Payload".to_vec(), Object::Array(vec![Object::Int(42); 8192])); + let resources = Object::Dict(resources); + let each = weight(&resources, 0); + d.update_dict(root, |dict| { + dict.set(b"Kids".to_vec(), Object::Array(kids)); + dict.set(b"Resources".to_vec(), resources); + }) + .unwrap(); + let budget = 1 << 20; + let pages: Vec = PageTree::with_budget(&d, budget).collect(); + let copied: usize = pages.iter().filter_map(|p| p.dict.get(b"Resources")).map(|r| weight(r, 0)).sum(); + assert!(copied <= budget, "{copied} bytes of inherited values copied into {} pages", pages.len()); + assert_eq!(pages.len(), budget / each); + assert_eq!(super::pages(&d).len(), 200); + } + #[test] fn repeated_children_do_not_queue_resource_copies() { let mut d = doc(0); From a0e82da864aa65e9386678feafd2f84c01a9159b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:48:08 +0000 Subject: [PATCH 087/142] fix(render): bound the replacement text copied into redraws Each unmarked redraw of an /ActualText span's glyphs took its own copy of the replacement text. The work budget counts comparisons, not bytes, so a long replacement around one glyph followed by many redraws at the same place copied it once per redraw: gigabytes from a small page. Redraws on one page share 4 MiB of replacement text; past it a redraw keeps its own glyphs, as it does past the work budget. --- crates/render/src/text.rs | 44 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 45c87bfea..11cde902f 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -420,6 +420,15 @@ const MAX_CELLS: i64 = 4096; /// their unmarked redraws (the text reads twice, as it would without `/ActualText`). const MAX_REDRAW_WORK: usize = 1 << 22; +/// Most replacement text bytes the redraw dedupe in `TextDevice::finish` copies on one page. +const MAX_REDRAW_TEXT: usize = 1 << 22; + +#[cfg(test)] +thread_local! { + /// Replacement text bytes given to redraws on this thread. + static REDRAW_TEXT_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + fn cell(v: f32) -> i64 { // Glyph boxes are finite (`draw_glyph` and `merge` drop the rest); `as` saturates. (v / CELL).floor() as i64 @@ -564,6 +573,7 @@ impl TextDevice { redraws.sort_by_key(|(range, _)| range.start); let glyphs = std::mem::take(&mut self.glyphs); let mut redraws = redraws.into_iter().peekable(); + let mut text_left = MAX_REDRAW_TEXT; let mut i = 0; while let Some(g) = glyphs.get(i) { let Some((range, k)) = redraws.next_if(|(range, _)| range.start == i) else { @@ -572,7 +582,18 @@ impl TextDevice { continue; }; let run = glyphs.get(range.clone()).unwrap_or_default(); - let text = self.replaced.get(k).map(|r| r.text.clone()).unwrap_or_default(); + let replacement = self.replaced.get(k).map_or("", |r| r.text.as_str()); + // Each redraw takes its own copy of the replacement, and one long replacement can + // have many redraws: past the budget a redraw keeps its glyphs, as past the work budget. + let Some(left) = text_left.checked_sub(replacement.len()) else { + self.glyphs.extend_from_slice(run); + i = range.end.max(i + 1); + continue; + }; + text_left = left; + let text = replacement.to_owned(); + #[cfg(test)] + REDRAW_TEXT_BYTES.with(|c| c.set(c.get() + text.len())); if !text.chars().all(|c| c.is_control()) { match merge(run, text) { Some(glyph) => self.glyphs.push(glyph), @@ -1871,6 +1892,27 @@ mod tests { extract_page(pdf, page, &InterpreterSettings::default()).unwrap() } + #[test] + fn redraws_of_a_long_replacement_share_a_text_budget() { + let long = "0041".repeat(32 * 1024); + let redraws = 200; + let content = format!( + "BT /F1 20 Tf 1 0 0 1 100 150 Tm /Span <>> BDC (x) Tj EMC {} ET", + "1 0 0 1 100 150 Tm (x) Tj ".repeat(redraws) + ); + let pdf = marked_pages(&[&content]); + REDRAW_TEXT_BYTES.with(|c| c.set(0)); + let text = marked_text(&pdf, 0); + let given = REDRAW_TEXT_BYTES.with(|c| c.get()); + assert!(given <= MAX_REDRAW_TEXT, "{given} bytes of replacement text given to {redraws} redraws"); + assert!(text.plain_text().starts_with("AAAA")); + // A short replacement still covers its redraw. + let pdf = marked_pages(&["BT /F1 20 Tf 1 0 0 1 100 150 Tm /Span <> BDC (x) Tj EMC 1 0 0 1 100 150 Tm (x) Tj ET"]); + REDRAW_TEXT_BYTES.with(|c| c.set(0)); + assert_eq!(marked_text(&pdf, 0).plain_text(), "fi"); + assert_eq!(REDRAW_TEXT_BYTES.with(|c| c.get()), 2); + } + #[test] fn text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei() { // Everyday content both ways: a string with a byte above 0x7F, and a later word holding From 24701fe9591e93cf18662cc89d4f8f8d01a44fe3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:00:42 +0000 Subject: [PATCH 088/142] fix(print): list the source pages once per imposition page_form listed every page of the document to take the one it printed, so imposing P pages walked the page tree and copied its leaf dictionaries P times over: P^2 page copies. impose passes the list it already holds. A page index outside it is an error, not a panic. --- crates/print/src/lib.rs | 23 ++++++++++++++++++++--- crates/print/src/tests.rs | 11 +++++++++++ crates/print/src/tests/appearances.rs | 3 ++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 6839e5f6b..5f81d1f93 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -770,13 +770,16 @@ fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option, ) -> Result { - let page = pdfcraft_model::pages(doc).swap_remove(index); + let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; let crop = page.crop(doc); let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut body = if content == Content::FormFieldsOnly { Vec::new() } else { decoded_contents(doc, &page.dict) }; @@ -841,10 +844,24 @@ fn page_form( Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) } +#[cfg(test)] +thread_local! { + /// Pages listed by [`source_pages`] on this thread. + static PAGES_LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The source document's pages, for printing. +fn source_pages(doc: &Document) -> Vec { + let pages = pdfcraft_model::pages(doc); + #[cfg(test)] + PAGES_LISTED.with(|c| c.set(c.get() + pages.len())); + pages +} + /// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a /// printer or is the user's own copy); callers must check the print permission first. pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { - let pages = pdfcraft_model::pages(src); + let pages = source_pages(src); let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); let sheets = layout(&sizes, settings)?; let mut doc = src.clone(); @@ -861,7 +878,7 @@ pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError let form = match forms.get(&pl.page) { Some(f) => *f, None => { - let f = page_form(&mut doc, pl.page, settings.content, &mut promoted)?; + let f = page_form(&mut doc, &pages, pl.page, settings.content, &mut promoted)?; forms.insert(pl.page, f); f } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 99634f1aa..ce736762e 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -192,6 +192,17 @@ fn custom_pages_per_sheet_never_overfill_the_grid() { assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), 3); } +#[test] +fn imposing_lists_the_pages_once() { + let n = 150; + let doc = fixture(n); + PAGES_LISTED.with(|c| c.set(0)); + let out = impose(&doc, &settings((0..n).collect(), Layout::multiple(2))).unwrap(); + let listed = PAGES_LISTED.with(|c| c.get()); + assert!(listed <= 2 * n, "{listed} pages listed to print {n}"); + assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), n / 2); +} + #[test] fn booklets_pair_pages_for_folding() { let sizes = vec![(200.0, 300.0); 6]; diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs index 8d6f56ff6..acf8ece6f 100644 --- a/crates/print/src/tests/appearances.rs +++ b/crates/print/src/tests/appearances.rs @@ -147,7 +147,8 @@ fn promotion_preserves_streams_and_explicit_subtypes() { for subtype in [None, Some(Object::name("Form")), Some(Object::name("Image")), Some(Object::Null), Some(Object::Int(7))] { let (mut doc, ap, _) = widget_fixture(subtype.clone(), Some([2.0, 0.0, 0.0, 3.0, 5.0, 7.0])); let original = doc.get(ap); - let form = page_form(&mut doc, 0, Content::Document, &mut std::collections::HashMap::new()).unwrap(); + let pages = pdfcraft_model::pages(&doc); + let form = page_form(&mut doc, &pages, 0, Content::Document, &mut std::collections::HashMap::new()).unwrap(); assert_eq!(doc.get(ap), original, "original in the print clone"); let promoted = xobject(&doc, doc.get(form).as_dict().unwrap(), b"PCPrA0"); if subtype.is_some() { From 3a11f39ef0ef58169a10d95598df832e9a5ff319 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:47:29 +0000 Subject: [PATCH 089/142] chore(cos): allow the boxed dictionary key index under clippy The index is boxed so that it adds one pointer to every Dict, and so to every Object, rather than an inline map. --- crates/cos/src/object.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 6b6a4befe..ee3c49a78 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -92,6 +92,8 @@ const INDEX_FROM: usize = 32; pub struct Dict { entries: Vec<(Name, Object)>, /// Key to position in `entries`, kept once the dictionary reaches [`INDEX_FROM`] entries. + /// Boxed so that every `Dict`, and so every `Object`, grows by one pointer, not by a map. + #[allow(clippy::box_collection)] index: Option>>, } From 735fb8e661f39293cd7417628ce6a541feb959bc Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:47:29 +0000 Subject: [PATCH 090/142] docs(filters, model): record the filter chain and inherited copy limits --- crates/filters/README.md | 2 +- crates/model/README.md | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/filters/README.md b/crates/filters/README.md index df64e4b7c..fc5306e32 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -23,7 +23,7 @@ encode_flate(data) -> Vec // zlib, level 6 ``` - `chain` is applied first to last, as in a `/Filter` array. -- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. Decryption belongs to `crypt`. +- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. So does a chain longer than `MAX_CHAIN` (8) filters, before any stage runs. Decryption belongs to `crypt`. - `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding. - `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors. diff --git a/crates/model/README.md b/crates/model/README.md index bfc6e42e3..09a060391 100644 --- a/crates/model/README.md +++ b/crates/model/README.md @@ -17,6 +17,9 @@ L3/L4 crates need: The page-tree walk keeps one cursor per ancestor and shares inherited attribute owners. Repeated child references are skipped before copying attributes, with first depth-first occurrence order preserved. Returned page dictionaries still own their effective attributes. +A walk copies at most `MAX_INHERITED_COPIES` (1 GiB, approximate heap bytes) of inherited +values into the leaves; past it the walk stops, as it does past its leaf cap, and the pages +after that point are not listed. `organize`, `annot` and `forms` still carry their own small walkers; they move here as the model grows (ADR-0004). From 110b7c678a1658c8b880be1d5f6d255c8f2e2248 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:29:44 +0000 Subject: [PATCH 091/142] fix(a11y): read the enclosing marked content in one step per operator Marked-text extraction and figure bounds searched the whole marked-content stack before every operator, so deeply nested unmarked BMC blocks made a page quadratic. The text stack now holds each level's effective id (its own, else the enclosing one's), and the bounds pass counts the open sequences that match. --- crates/a11y/src/alt.rs | 17 ++++++++++++---- crates/a11y/src/headings.rs | 21 +++++++++++-------- crates/a11y/src/lib.rs | 14 +++++++++++++ crates/a11y/src/tests.rs | 40 +++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index e4755ab38..ed91f9e50 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -183,16 +183,19 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { } /// Where the marked content `ids` is drawn on a page (images, forms and paths). -fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { +pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let props = page_properties(doc, page); let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]; + // Whether each open marked-content sequence is one of `ids`, and how many of them are. let mut marks: Vec = Vec::new(); + let mut matching = 0usize; let mut bbox = None; let mut path: Vec<(f64, f64)> = Vec::new(); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { - let inside = marks.iter().any(|m| *m); + crate::count_mark_reads(1); + let inside = matching > 0; let top = ctm.last().copied().unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); match op.op.as_slice() { b"q" => ctm.push(top), @@ -207,9 +210,15 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; } } b"BMC" => marks.push(false), - b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))), + b"BDC" => { + let wanted = bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n)); + matching += usize::from(wanted); + marks.push(wanted); + } b"EMC" => { - marks.pop(); + if marks.pop() == Some(true) { + matching = matching.saturating_sub(1); + } } b"m" | b"l" => { if let Some([x, y]) = op.nums::<2>() { diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 537ed908d..d3a478cb9 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -182,6 +182,7 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let res = sub(doc, page, b"Resources"); let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties")); let mut metrics: HashMap, Metrics> = HashMap::new(); + // The id each open marked-content sequence is in: its own, else the enclosing one's. let mut stack: Vec> = Vec::new(); let mut out: HashMap = HashMap::new(); // Where each id's last glyph ended, in user space. @@ -191,16 +192,20 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let mut unsaved = 0usize; let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { - let id = stack.iter().rev().find_map(|m| *m); + crate::count_mark_reads(1); + let id = stack.last().copied().flatten(); match op.op.as_slice() { - b"BMC" => stack.push(None), - b"BDC" => stack.push(match op.operands.get(1) { - Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int), - Some(Object::Name(n)) => { - props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))) + b"BMC" => stack.push(id), + b"BDC" => stack.push( + match op.operands.get(1) { + Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int), + Some(Object::Name(n)) => { + props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))) + } + _ => None, } - _ => None, - }), + .or(id), + ), b"EMC" => { stack.pop(); } diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index ff81cc4c9..22fac756e 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -21,6 +21,20 @@ mod report; mod structure; mod tags; +#[cfg(test)] +thread_local! { + /// Marked-content stack entries read to decide which marks enclose an operator, for the tests + /// that keep that constant per operator. + static MARK_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` marked-content stack reads (tests only). +#[inline] +fn count_mark_reads(_n: usize) { + #[cfg(test)] + MARK_READS.with(|c| c.set(c.get().saturating_add(_n))); +} + pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt}; pub use autotag::{AutoTagError, AutoTagResult, autotag_text}; pub use autotag_fields::tag_form_fields; diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 15ca643e8..b5eb54742 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1023,6 +1023,46 @@ fn glyph_by_tm(text: &str, size: f64, y: f64) -> String { ops + " ET" } +/// Marked-content stack reads while `f` runs. +fn mark_reads(f: impl FnOnce()) -> usize { + MARK_READS.with(|c| c.set(0)); + f(); + MARK_READS.with(|c| c.get()) +} + +/// Page content nesting `depth` unmarked `BMC` blocks around a marked one, with `ops` operators inside. +fn deeply_nested_page(doc: &mut Document, depth: usize, ops: usize) -> pdfcraft_cos::Dict { + use pdfcraft_cos::{Dict, Stream}; + let mut data = "/Figure << /MCID 0 >> BDC ".repeat(1); + data.push_str(&"/X BMC ".repeat(depth)); + data.push_str(&"0 0 1 1 re f ".repeat(ops)); + data.push_str(&"EMC ".repeat(depth + 1)); + let content = doc.add(Stream::flate(Dict::new(), data.as_bytes())); + [(b"Contents".to_vec(), Object::Ref(content))].into_iter().collect() +} + +#[test] +fn marked_text_reads_its_enclosing_id_in_constant_steps() { + let mut doc = Document::new_empty(); + let page = deeply_nested_page(&mut doc, 5_000, 5_000); + let reads = mark_reads(|| { + crate::headings::mcid_texts(&doc, &page); + }); + // About 15 000 operators; a scan of the stack per operator reads millions of entries. + assert!(reads <= 4 * 15_000, "{reads} stack reads"); +} + +#[test] +fn figure_bounds_read_the_marked_content_state_in_constant_steps() { + let mut doc = Document::new_empty(); + let page = deeply_nested_page(&mut doc, 5_000, 5_000); + // No mark encloses figure 7, so a scan of the stack per operator reads all of it. + let reads = mark_reads(|| { + crate::alt::content_bbox(&doc, &page, &std::collections::HashSet::from([7])); + }); + assert!(reads <= 4 * 15_000, "{reads} stack reads"); +} + /// One tagged page whose H1 headings show `contents` (one marked-content sequence each). fn heading_page(contents: &[String]) -> Document { let marked: Vec = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect(); From 38211588c754da1a7319dc5fa0f2b0bf54069815 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:32:02 +0000 Subject: [PATCH 092/142] fix(a11y): find every figure's bounds on a page in one pass Figure listing decoded and parsed a page's content once per figure on it, so many small Figure elements pointing at one page multiplied the work. The figures are grouped by page and one pass gathers the bounds of every marked-content id they own; what a sequence draws also counts for the sequences around it, as before. --- crates/a11y/src/alt.rs | 86 ++++++++++++++++++++++++++++++---------- crates/a11y/src/lib.rs | 2 + crates/a11y/src/tests.rs | 23 ++++++++++- 3 files changed, 89 insertions(+), 22 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index ed91f9e50..fe8eb292c 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -182,16 +182,47 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { } } -/// Where the marked content `ids` is drawn on a page (images, forms and paths). -pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { +/// An open marked-content sequence while bounds are gathered: its id, when it is one of those asked +/// for, and the bounds of what has been drawn in it so far. +struct Mark { + id: Option, + drawn: Option<[f64; 4]>, +} + +/// Adds `b` to what the innermost open sequence has drawn. +fn draw(marks: &mut [Mark], b: [f64; 4]) { + if let Some(m) = marks.last_mut() { + m.drawn = Some(union(m.drawn, b)); + } +} + +/// Closes the innermost open sequence: what it drew counts for its id and for the sequence around it. +fn close(marks: &mut Vec, matching: &mut usize, out: &mut HashMap) { + let Some(m) = marks.pop() else { return }; + if m.id.is_some() { + *matching = matching.saturating_sub(1); + } + let Some(b) = m.drawn else { return }; + if let Some(id) = m.id { + let e = out.entry(id).or_insert(b); + *e = union(Some(*e), b); + } + draw(marks, b); +} + +/// Where each of the marked content `ids` is drawn on a page (images, forms and paths), in one pass +/// over the page. What a sequence draws counts for every sequence of `ids` around it too. +pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet) -> HashMap { let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let props = page_properties(doc, page); + #[cfg(test)] + crate::BOUNDS_PASSES.with(|c| c.set(c.get() + 1)); let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]; - // Whether each open marked-content sequence is one of `ids`, and how many of them are. - let mut marks: Vec = Vec::new(); + // The open marked-content sequences, and how many of them are one of `ids`. + let mut marks: Vec = Vec::new(); let mut matching = 0usize; - let mut bbox = None; + let mut out = HashMap::new(); let mut path: Vec<(f64, f64)> = Vec::new(); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { crate::count_mark_reads(1); @@ -209,17 +240,13 @@ pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> O *t = m.then(&top); } } - b"BMC" => marks.push(false), + b"BMC" => marks.push(Mark { id: None, drawn: None }), b"BDC" => { - let wanted = bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n)); - matching += usize::from(wanted); - marks.push(wanted); - } - b"EMC" => { - if marks.pop() == Some(true) { - matching = matching.saturating_sub(1); - } + let id = bdc_mcid(doc, &props, &op).filter(|n| ids.contains(n)); + matching += usize::from(id.is_some()); + marks.push(Mark { id, drawn: None }); } + b"EMC" => close(&mut marks, &mut matching, &mut out), b"m" | b"l" => { if let Some([x, y]) = op.nums::<2>() { path.push(top.apply(x, y)); @@ -240,7 +267,7 @@ pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> O b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"n" => { if inside && !matches!(op.op.as_slice(), b"n") { for (x, y) in &path { - bbox = Some(union(bbox, [*x, *y, *x, *y])); + draw(&mut marks, [*x, *y, *x, *y]); } } path.clear(); @@ -260,13 +287,16 @@ pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> O } _ => top.bbox([0.0, 0.0, 1.0, 1.0]), }; - bbox = Some(union(bbox, unit)); + draw(&mut marks, unit); } - b"BI" if inside => bbox = Some(union(bbox, top.bbox([0.0, 0.0, 1.0, 1.0]))), + b"BI" if inside => draw(&mut marks, top.bbox([0.0, 0.0, 1.0, 1.0])), _ => {} } } - bbox + while !marks.is_empty() { + close(&mut marks, &mut matching, &mut out); + } + out } /// The figures, in document order. @@ -275,14 +305,28 @@ pub fn figures(doc: &Document) -> Vec
{ let role_map = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let pages = pdfcraft_model::pages(doc); let index: HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); - elements(doc) + let figures: Vec<(Element, Option)> = elements(doc) .into_iter() .filter(|e| e.dict.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure")) .map(|e| { let page = e.page.and_then(|p| index.get(&p).copied()); + (e, page) + }) + .collect(); + let mut wanted: HashMap> = HashMap::new(); + for (e, page) in &figures { + if let Some(p) = page { + wanted.entry(*p).or_default().extend(mcids(&e.dict)); + } + } + let bounds: HashMap> = + wanted.into_iter().filter_map(|(p, ids)| Some((p, content_bboxes(doc, &pages.get(p)?.dict, &ids)))).collect(); + figures + .into_iter() + .map(|(e, page)| { let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())); - let ids: HashSet = mcids(&e.dict).into_iter().collect(); - let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids)); + let on_page = page.and_then(|p| bounds.get(&p)); + let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b)))); Figure { obj: e.object, page, alt, bbox } }) .collect() diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 22fac756e..21d8ea582 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -26,6 +26,8 @@ thread_local! { /// Marked-content stack entries read to decide which marks enclose an operator, for the tests /// that keep that constant per operator. static MARK_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Page content passes made to find figure bounds. + static BOUNDS_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Counts `n` marked-content stack reads (tests only). diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index b5eb54742..a24f59edb 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1058,11 +1058,32 @@ fn figure_bounds_read_the_marked_content_state_in_constant_steps() { let page = deeply_nested_page(&mut doc, 5_000, 5_000); // No mark encloses figure 7, so a scan of the stack per operator reads all of it. let reads = mark_reads(|| { - crate::alt::content_bbox(&doc, &page, &std::collections::HashSet::from([7])); + crate::alt::content_bboxes(&doc, &page, &std::collections::HashSet::from([7])); }); assert!(reads <= 4 * 15_000, "{reads} stack reads"); } +#[test] +fn figure_bounds_take_one_pass_over_each_page() { + let n = 50; + let content: Vec = (0..n).map(|i| format!("/Figure << /MCID {i} >> BDC {i} 0 1 1 re f EMC")).collect(); + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 6 + i)).collect(); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(), + stream(&content.join(" ")), + format!("<< /Type /StructTreeRoot /K [{}] >>", kids.join(" ")), + ]; + objs.extend((0..n).map(|i| format!("<< /S /Figure /Pg 3 0 R /K {i} >>"))); + let doc = pdf(&objs, ""); + BOUNDS_PASSES.with(|c| c.set(0)); + let f = figures(&doc); + assert_eq!(BOUNDS_PASSES.with(|c| c.get()), 1, "page passes"); + assert_eq!(f.len(), n); + assert!(f.iter().enumerate().all(|(i, f)| f.bbox == Some([i as f64, 0.0, i as f64 + 1.0, 1.0]))); +} + /// One tagged page whose H1 headings show `contents` (one marked-content sequence each). fn heading_page(contents: &[String]) -> Document { let marked: Vec = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect(); From 1cc392e5b04ada01b130236c6d101636e8bb62da Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:33:28 +0000 Subject: [PATCH 093/142] fix(fonts): cut ToUnicode destinations to the CMap limit A sequential bfrange stores one destination and builds every covered glyph's text from all of it, so one huge destination and many repeats of its codes made text extraction copy megabytes per glyph. Destinations keep at most 256 UTF-16 units (the format's 512 bytes), and marked-text extraction stops asking for a glyph's text once its id has all the text it keeps. --- crates/a11y/src/headings.rs | 12 ++++++------ crates/fonts/src/pdf.rs | 22 ++++++++++++++++++++-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index d3a478cb9..41c749ed3 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -285,17 +285,17 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { }; for (code, len) in m.codes(&s.bytes) { let trm = tm.then(&gs.ctm); + // A glyph's text is only built while its id still takes more. if let Some(id) = id + && out.get(&id).is_none_or(|t| t.len() < MAX_TITLE * 4) && let Some(glyph) = m.text_of(code) { let text = out.entry(id).or_default(); - if text.len() < MAX_TITLE * 4 { - let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale)); - if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') { - text.push(' '); - } - text.push_str(&glyph); + let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale)); + if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') { + text.push(' '); } + text.push_str(&glyph); } let advance = m.width(code) * gs.size + gs.char_spacing + if m.is_space(code, len) { gs.word_spacing } else { 0.0 }; tm = Matrix::translate(advance * gs.scale, 0.0).then(&tm); diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0aaed6939..0badcfc3a 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -905,9 +905,13 @@ impl Metrics { } } -/// The UTF-16 code units of CMap hex bytes. +/// Most UTF-16 units kept from one ToUnicode destination: the CMap format allows destinations of up +/// to 512 bytes, and every glyph a sequential range covers builds its text from the whole of one. +const MAX_DESTINATION_UNITS: usize = 256; + +/// The UTF-16 code units of CMap hex bytes, at most [`MAX_DESTINATION_UNITS`] of them. fn code_units(b: &[u8]) -> Vec { - b.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect() + b.chunks(2).take(MAX_DESTINATION_UNITS).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect() } /// UTF-16BE (with surrogates) from CMap hex bytes. @@ -1262,6 +1266,20 @@ mod tests { use super::*; + /// Every glyph of a sequential range builds its text from the destination, so a long one is cut + /// to the CMap limit once, when it is read. + #[test] + fn unicode_destinations_are_cut_to_the_cmap_limit() { + let long = "0041".repeat(4096); + let cmap = format!("2 beginbfrange <0010> <0020> <{long}> <0030> <0030> [<{long}>] endbfrange 1 beginbfchar <0040> <{long}> endbfchar"); + let mut u = Unicode::default(); + parse_to_unicode(cmap.as_bytes(), &mut u); + for code in [0x10, 0x20, 0x30, 0x40] { + let text = u.text(code).unwrap_or_default(); + assert!(!text.is_empty() && text.len() <= MAX_DESTINATION_UNITS * 3, "code {code:#x}: {} bytes", text.len()); + } + } + /// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one. #[test] fn low_code_tables_end_with_the_last_covered_code() { From 75c4275810edafaa3b607d136b18569d18c797cd Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:35:00 +0000 Subject: [PATCH 094/142] fix(fonts): look up CID and width ranges by binary search Code-to-CID and /W range lookups scanned every record in declaration order, once per glyph and once per explicit ToUnicode entry while a font was read, so a few thousand records multiplied by as many codes took billions of steps. Both tables are indexed once into stretches whose first-declared covering record wins, the precedence the scan had, and the /W ranges stop at the CMap entry limit. --- crates/fonts/src/pdf.rs | 157 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 141 insertions(+), 16 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0badcfc3a..4a118a07d 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -33,9 +33,9 @@ pub struct Metrics { widths: Vec, /// Composite fonts: CID → width, and CID ranges with one width. cid_widths: HashMap, - cid_ranges: Vec<(u32, u32, f64)>, + cid_ranges: Spans, /// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`). - cid_map: Vec<(u32, u32, u32)>, + cid_map: Spans, default: f64, /// Composite fonts with an explicit `/DW`: `/W` may leave out the glyphs that have that /// width (Chrome and Skia do), so a CID missing from `/W` is not a missing glyph. @@ -598,8 +598,8 @@ impl Metrics { first: 0, widths: Vec::new(), cid_widths: HashMap::new(), - cid_ranges: Vec::new(), - cid_map: Vec::new(), + cid_ranges: Spans::default(), + cid_map: Spans::default(), default: 500.0, explicit_default: false, std14: Some(crate::Std14::Helvetica), @@ -685,7 +685,7 @@ impl Metrics { if !spaces.is_empty() { m.codes = Codes::Ranges(spaces); } - m.cid_map = cids; + m.cid_map = Spans::new(cids); } let desc = font .get(b"DescendantFonts") @@ -698,6 +698,7 @@ impl Metrics { m.explicit_default = true; } if let Some(w) = desc.get(b"W").map(|w| doc.resolve(w)).and_then(|w| w.as_array().cloned()) { + let mut ranges = Vec::new(); let mut i = 0; while i < w.len() { let Some(c0) = doc.resolve(&w[i]).as_f64().map(|v| v as u32) else { break }; @@ -714,13 +715,16 @@ impl Metrics { } Some(c1) => { if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) { - m.cid_ranges.push((c0, c1 as u32, v)); + if ranges.len() < MAX_CMAP_ENTRIES { + ranges.push((c0, c1 as u32, v)); + } } i += 3; } None => break, } } + m.cid_ranges = Spans::new(ranges); } descriptor = dict(doc, desc.get(b"FontDescriptor")); } else { @@ -791,14 +795,14 @@ impl Metrics { if self.cid_map.is_empty() { return code; } - self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c.saturating_add(code - lo)) + self.cid_map.find(code).map_or(0, |(lo, _, c)| c.saturating_add(code.saturating_sub(*lo))) } /// The advance of `code` in text space per unit of font size. pub fn width(&self, code: u32) -> f64 { if self.composite { let cid = self.cid(code); - let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.iter().find(|(a, b, _)| cid >= *a && cid <= *b).map(|r| r.2)); + let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.find(cid).map(|r| r.2)); return w.unwrap_or(self.default) * self.scale; } if let Some(w) = code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) { @@ -858,7 +862,7 @@ impl Metrics { // A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for // those, only the subset's program says whether the glyph is there. return self.cid_widths.contains_key(&cid) - || self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b) + || self.cid_ranges.find(cid).is_some() || !self.subset || self.outlined_cids.get(cid as usize / 64).is_some_and(|w| (*w >> (cid % 64)) & 1 == 1) // No readable TrueType program to ask (e.g. CFF or missing): an explicit /DW @@ -958,16 +962,69 @@ fn add_range(out: &mut Unicode, budget: &mut usize, seq: &mut u32, lo: u32, hi: true } +#[cfg(test)] +thread_local! { + /// Code span records read by CID and width lookups, for the tests that keep a lookup logarithmic. + static SPAN_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts one span record read (tests only). +#[inline] +fn count_span_reads() { + #[cfg(test)] + SPAN_READS.with(|c| c.set(c.get().saturating_add(1))); +} + +/// Code spans `(lo, hi, value)` in declaration order, where the first-declared span covering a code +/// wins, indexed so that a lookup is one binary search. +#[derive(Clone, Debug, Default, PartialEq)] +struct Spans { + spans: Vec<(u32, u32, T)>, + /// The span covering each stretch of codes; see [`stretches`]. + cover: Vec<(u64, Option)>, +} + +impl Spans { + fn new(spans: Vec<(u32, u32, T)>) -> Self { + let bounds: Vec<(u32, u32)> = spans.iter().map(|s| (s.0, s.1)).collect(); + Spans { cover: stretches(&bounds, false), spans } + } + + fn is_empty(&self) -> bool { + self.spans.is_empty() + } + + /// The first-declared span covering `code`. + fn find(&self, code: u32) -> Option<&(u32, u32, T)> { + let step = self.cover.partition_point(|&(start, _)| { + count_span_reads(); + start <= u64::from(code) + }); + let &(_, record) = self.cover.get(step.checked_sub(1)?)?; + self.spans.get(record?) + } +} + /// For each stretch of codes, from its first code on, the latest-declared range covering it (`None` /// where no range does), sorted by code; a lookup is one binary search over it. fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option)> { + let spans: Vec<(u32, u32)> = ranges.iter().map(|r| (r.lo, r.hi)).collect(); + stretches(&spans, true) +} + +/// For each stretch of codes, from its first code on, the span `(lo, hi)` covering it (`None` where +/// none does), sorted by code: the latest-declared one when `later_wins`, else the first-declared. +/// At most `u32::MAX` spans are indexed. +fn stretches(spans: &[(u32, u32)], later_wins: bool) -> Vec<(u64, Option)> { + // Heap keys, largest for the winning record; the mapping is its own inverse. + let key = |record: u32| if later_wins { record } else { !record }; // Openings (first code, record) and closings (one past the last code), each in code order. The cover // can only change at one of their codes, so the sweep visits those codes in order. - let mut opens: Vec<(u32, u32)> = ranges.iter().enumerate().map(|(i, r)| (r.lo, i as u32)).collect(); + let mut opens: Vec<(u32, u32)> = spans.iter().zip(0..u32::MAX).map(|(&(lo, _), i)| (lo, i)).collect(); opens.sort_unstable(); - let mut closes: Vec = ranges.iter().map(|r| u64::from(r.hi) + 1).collect(); + let mut closes: Vec = spans.iter().take(opens.len()).map(|&(_, hi)| u64::from(hi) + 1).collect(); closes.sort_unstable(); - // Records that have opened and not yet closed; the top is the latest declared one. + // Keys of the records that have opened and not yet closed; the top is the winning one. let mut open: BinaryHeap = BinaryHeap::new(); let mut cover: Vec<(u64, Option)> = Vec::new(); let (mut next_open, mut next_close) = (0, 0); @@ -981,16 +1038,16 @@ fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option)> { while let Some(&(lo, record)) = opens.get(next_open) && u64::from(lo) <= p { - open.push(record); + open.push(key(record)); next_open += 1; } while closes.get(next_close).is_some_and(|&end| end <= p) { next_close += 1; } - while open.peek().is_some_and(|&top| ranges.get(top as usize).is_none_or(|r| u64::from(r.hi) < p)) { + while open.peek().is_some_and(|&top| spans.get(key(top) as usize).is_none_or(|&(_, hi)| u64::from(hi) < p)) { open.pop(); } - let winner = open.peek().map(|&top| top as usize); + let winner = open.peek().map(|&top| key(top) as usize); if cover.last().map(|&(_, w)| w) != Some(winner) { cover.push((p, winner)); } @@ -1266,6 +1323,74 @@ mod tests { use super::*; + /// Span records read while `f` runs. + fn span_reads(f: impl FnOnce()) -> usize { + SPAN_READS.with(|c| c.set(0)); + f(); + SPAN_READS.with(|c| c.get()) + } + + /// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`. + fn composite(doc: &mut Document, encoding: &str, w: Vec, to_unicode: &str) -> Dict { + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set(b"W".to_vec(), Object::Array(w)); + let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes())))); + let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes())))); + font( + doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", enc), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", tu), + ], + ) + } + + /// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all. + #[test] + fn widths_of_a_code_no_range_covers_take_few_steps() { + let mut doc = Document::new_empty(); + let w: Vec = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect(); + let f = composite(&mut doc, "", w, ""); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.width(4), 0.5); + let reads = span_reads(|| { + for _ in 0..1000 { + assert_eq!(m.width(3), 1.0); + } + }); + assert!(reads <= 1000 * 64, "{reads} span reads"); + } + + /// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it. + #[test] + fn overlapping_spans_resolve_to_the_first_declared() { + let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]); + let found = |code| spans.find(code).map(|s| s.2); + let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))]; + for (code, value) in want { + assert_eq!(found(code), value, "code {code}"); + } + } + + /// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map. + #[test] + fn many_unicode_entries_look_up_their_cids_in_few_steps() { + let n = 2000; + let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect(); + let encoding = format!("1 begincodespacerange <0000> endcodespacerange {n} begincidchar {cidchars} endcidchar"); + let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect(); + let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar"); + let mut doc = Document::new_empty(); + let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode); + let reads = span_reads(|| { + Metrics::from_dict(&doc, &f); + }); + assert!(reads <= n * 64, "{reads} span reads"); + } + /// Every glyph of a sequential range builds its text from the destination, so a long one is cut /// to the CMap limit once, when it is read. #[test] @@ -1736,7 +1861,7 @@ mod tests { let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); let mut m = Metrics::fallback(); - (_, m.cid_map) = parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange"); + m.cid_map = Spans::new(parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange").1); assert_eq!(m.cid(5), u32::MAX); let mut doc = Document::new_empty(); let mut enc = Dict::new(); From 5609bcbe37728d65186303b4c8817dbdb8e8c3b8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:36:06 +0000 Subject: [PATCH 095/142] fix(fonts): wrap text measuring each word once Line wrapping built and measured the whole candidate line for every word, so a long field value or annotation text at a tiny font size, with many words on one line, took quadratic time and copying. The wrapper keeps the line's width as it goes and measures each word, space and broken character once. --- crates/fonts/src/lib.rs | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index ec103ed5f..37a2ea74d 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -88,17 +88,28 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec { } /// [`wrap`], measuring with `measure` (for example [`cjk_width`]). +/// +/// A line measures as the sum of its words, the spaces between them and the characters of a broken +/// word, each measured once, so the work stays linear in `text` however much fits on a line. pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec { let mut lines = Vec::new(); + let space = measure(" ", size); + let mut buf = [0u8; 4]; // A CRLF pair is one break, a lone CR or LF is one break each. for para in text.replace("\r\n", "\n").split(['\n', '\r']) { let mut line = String::new(); + let mut line_width = 0.0; // No word is on the line yet; an empty word (a leading space) still starts it. let mut fresh = true; for word in para.split(' ') { - let candidate = if fresh { word.to_string() } else { format!("{line} {word}") }; - if measure(&candidate, size) <= width || fresh && measure(word, size) <= width { - line = candidate; + let word_width = measure(word, size); + let candidate = if fresh { word_width } else { line_width + space + word_width }; + if candidate <= width { + if !fresh { + line.push(' '); + } + line.push_str(word); + line_width = candidate; fresh = false; continue; } @@ -108,11 +119,15 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) } else { lines.push(std::mem::take(&mut line)); } + line_width = 0.0; for ch in word.chars() { - if !line.is_empty() && measure(&format!("{line}{ch}"), size) > width { + let ch_width = measure(ch.encode_utf8(&mut buf), size); + if !line.is_empty() && line_width + ch_width > width { lines.push(std::mem::take(&mut line)); + line_width = 0.0; } line.push(ch); + line_width += ch_width; } fresh = line.is_empty(); } @@ -442,4 +457,20 @@ mod tests { let lines = wrap_with("日本語のテキストは文字単位で折り返されます", 10.0, 45.0, cjk_width); assert!(lines.len() > 3 && lines.iter().all(|l| cjk_width(l, 10.0) <= 45.0), "{lines:?}"); } + + /// Wrapping measures each word and character a bounded number of times, however many fit on a line. + #[test] + fn wrapping_measures_each_word_a_few_times() { + let measured = std::cell::Cell::new(0usize); + let measure = |s: &str, size: f64| { + measured.set(measured.get() + s.len()); + helvetica_width(s, size) + }; + let text = format!("{} {}", "word ".repeat(5_000), "x".repeat(5_000)); + let lines = wrap_with(&text, 1.0, 13_000.0, measure); + assert_eq!(lines.len(), 2, "the words fit on one line, the long word on another"); + assert!(measured.get() <= 4 * text.len(), "{} bytes measured", measured.get()); + let narrow = wrap_with(&text, 10.0, 100.0, helvetica_width); + assert!(narrow.iter().all(|l| helvetica_width(l, 10.0) <= 100.0), "{narrow:?}"); + } } From fcedb156893891c718fca03dd27c9a04ba5ae110 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:36:48 +0000 Subject: [PATCH 096/142] fix(export): find font size and colour slots through a map The body-size tally and the RTF colour table searched every slot seen so far for each block, and the RTF writer searched the colour table again for every paragraph and cell, so a document with many distinct sizes or colours made export quadratic. Both keep their first-seen order and look slots up in a hash map. --- crates/export/src/lib.rs | 75 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index f3030ad58..8c3999712 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -8,6 +8,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::collections::HashMap; + mod pptx; mod zip; @@ -85,14 +87,33 @@ enum Item<'a> { PageBreak, } +#[cfg(test)] +thread_local! { + /// Entries read to find a font size's or a colour's slot, for the tests that keep it constant. + static LOOKUP_STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` entries read by a lookup (tests only). +#[inline] +fn count_lookup_steps(_n: usize) { + #[cfg(test)] + LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n))); +} + /// The body text size: the size most of the document's characters are set in. fn body_size(pages: &[Page]) -> f64 { + // Sizes in first-seen order (which breaks ties), and where each one is in it. let mut by: Vec<(i64, usize)> = Vec::new(); + let mut slot: HashMap = HashMap::new(); for b in pages.iter().flat_map(|p| &p.blocks) { let k = (b.size * 2.0).round() as i64; - match by.iter_mut().find(|e| e.0 == k) { - Some(e) => e.1 += b.text.len(), - None => by.push((k, b.text.len())), + count_lookup_steps(1); + let i = *slot.entry(k).or_insert_with(|| { + by.push((k, 0)); + by.len() - 1 + }); + if let Some(e) = by.get_mut(i) { + e.1 = e.1.saturating_add(b.text.len()); } } by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0) @@ -831,9 +852,12 @@ pub fn rtf(pages: &[Page]) -> String { let items = items(pages); // The colour table: entry 0 is the default (auto) colour, then each non-black colour once. let mut colors: Vec<[u8; 3]> = Vec::new(); + let mut slot: HashMap<[u8; 3], usize> = HashMap::new(); let mut note = |c: [f64; 3]| { let c = rgb8(c); - if c != [0, 0, 0] && !colors.contains(&c) { + count_lookup_steps(1); + if c != [0, 0, 0] && !slot.contains_key(&c) { + slot.insert(c, colors.len()); colors.push(c); } }; @@ -847,7 +871,8 @@ pub fn rtf(pages: &[Page]) -> String { // `\cfN` for a colour (nothing for black, which keeps the default). let cf = |c: [f64; 3]| { let c = rgb8(c); - colors.iter().position(|e| *e == c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) + count_lookup_steps(1); + slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) }; let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); if !colors.is_empty() { @@ -911,6 +936,46 @@ pub fn rtf(pages: &[Page]) -> String { mod tests { use super::*; + /// A page of `n` one-word blocks, each in its own size and colour. + fn varied_page(n: usize) -> Page { + let blocks = (0..n) + .map(|i| Block { + text: "word".into(), + rect: [72.0, i as f64, 100.0, i as f64 + 1.0], + size: 4.0 + i as f64 * 0.5, + bold: false, + italic: false, + color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5], + rotation: 0.0, + }) + .collect(); + Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None } + } + + fn lookup_steps(f: impl FnOnce()) -> usize { + LOOKUP_STEPS.with(|c| c.set(0)); + f(); + LOOKUP_STEPS.with(|c| c.get()) + } + + #[test] + fn many_distinct_sizes_are_counted_in_constant_steps_each() { + let pages = [varied_page(3000)]; + let steps = lookup_steps(|| { + body_size(&pages); + }); + assert!(steps <= 2 * 3000, "{steps} steps"); + } + + #[test] + fn many_distinct_colours_take_constant_steps_each() { + let pages = [varied_page(3000)]; + let mut out = String::new(); + let steps = lookup_steps(|| out = rtf(&pages)); + assert!(steps <= 4 * 3000, "{steps} steps"); + assert!(out.contains("\\cf3000 ") || out.contains("\\cf3000\\"), "the last colour has the last slot"); + } + fn page() -> Page { let b = |t: &str, y: f64, size: f64, bold: bool| Block { text: t.into(), From dba51fc8237dd04b5cd594d5fa56106e902d74b8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:37:39 +0000 Subject: [PATCH 097/142] fix(optimize): refuse a JPEG whose own size differs from its dictionary's The pixel reader capped the image size the dictionary gives, then decoded the JPEG at the size its header gives and compared afterwards, so a small image dictionary over a huge JPEG allocated the huge one first. The reader takes the size from the JPEG header and refuses a mismatch before decoding. --- crates/optimize/src/images.rs | 40 ++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 6182b0892..1d8a59e25 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -227,6 +227,19 @@ fn is_dct(s: &Stream) -> Option { } } +#[cfg(test)] +thread_local! { + /// Pixels decoded from JPEG data, for the test that keeps a mismatched JPEG from decoding. + static DECODED_PIXELS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` decoded pixels (tests only). +#[inline] +fn count_decoded(_n: u64) { + #[cfg(test)] + DECODED_PIXELS.with(|c| c.set(c.get().saturating_add(_n))); +} + /// 8-bit samples of an image (`n` components), decoded. pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?); @@ -235,7 +248,15 @@ pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { } let bytes = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)?; if is_dct(s)? { - let img = image::load_from_memory_with_format(&s.raw, image::ImageFormat::Jpeg).ok()?; + use image::ImageDecoder as _; + // The JPEG's own size is what gets decoded: one other than the dictionary's is refused from + // its header, before any pixels are. + let decoder = image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(&s.raw[..])).ok()?; + if decoder.dimensions() != (w, h) { + return None; + } + let img = image::DynamicImage::from_decoder(decoder).ok()?; + count_decoded(u64::from(img.width()) * u64::from(img.height())); // Adobe CMYK/YCCK JPEGs aren't handled (the colour space check excludes them). let v = if n == 1 { img.into_luma8().into_raw() } else { img.into_rgb8().into_raw() }; (v.len() == bytes).then_some(v) @@ -432,4 +453,21 @@ mod dimension_tests { let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]); assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4])); } + + /// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded. + #[test] + fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() { + let data = jpeg(&vec![128; 1000 * 1000], 1, 1000, 1000, 50).unwrap_or_default(); + let stream = |w: i64, h: i64| { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(w)); + dict.set(b"Height".to_vec(), Object::Int(h)); + dict.set(b"Filter".to_vec(), Object::name("DCTDecode")); + Stream::from_raw(dict, data.clone()) + }; + DECODED_PIXELS.with(|c| c.set(0)); + assert!(pixels(&stream(10, 10), 1).is_none()); + assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded"); + assert_eq!(pixels(&stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000)); + } } From 6b62585339b351d1bf8827377aa01117d5a1b4f7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:39:09 +0000 Subject: [PATCH 098/142] fix(optimize): bound the search for fragment runs on a page A run of images that is not one picture was searched again from each of its groups, collecting the rest of the run every time, so many images drawn over one another made a page quadratic. The search reads at most 16 groups per content operator (at least 65 536) and leaves the rest of the page as it is once that is spent. --- crates/optimize/src/fragments.rs | 55 ++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index caf617f71..44e165546 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -29,6 +29,19 @@ pub const MIN_FRAGMENTS: usize = 4; /// Most pixels a merged image may have (memory). const MAX_PIXELS: u64 = 80_000_000; +/// Fragment groups a page's search for runs may read per content operator, and at least. A run +/// that is not one picture is searched again from each of its groups, so without a limit images +/// drawn over one another take time quadratic in their number; past it, the rest of the page is +/// left as it is. +const SCAN_PER_OP: usize = 16; +const MIN_SCAN: usize = 1 << 16; + +#[cfg(test)] +thread_local! { + /// Fragment groups read, for the test that keeps a page's search linear in its content. + static GROUP_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// One fragment: its image, its box in the coordinates the run is drawn in, and its size. struct Fragment { image: ObjRef, @@ -40,6 +53,8 @@ struct Fragment { /// The ops `q`, one or more `cm`, `Do`, `Q` starting at `i`: the index after them, the matrix /// and the name drawn. fn group_at(ops: &[Op], i: usize) -> Option<(usize, Matrix, Vec)> { + #[cfg(test)] + GROUP_READS.with(|c| c.set(c.get().saturating_add(1))); if !ops.get(i)?.is("q") { return None; } @@ -226,7 +241,13 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: & let ops = parsed.ops; let mut out: Vec = Vec::with_capacity(ops.len()); let (mut i, mut merged, mut k) = (0, 0, 0); + let budget = ops.len().saturating_mul(SCAN_PER_OP).max(MIN_SCAN); + let mut scanned = 0usize; while i < ops.len() { + if scanned > budget { + out.extend(ops.get(i..).unwrap_or_default().iter().cloned()); + break; + } // The longest run of fragment groups from here with one colour space. let mut run = Vec::new(); let mut kind: Option<(Object, usize)> = None; @@ -245,10 +266,12 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: & run.push(Fragment { image, rect: [e, f, e + a, f + d], width, height }); j = next; } + scanned = scanned.saturating_add(run.len()).saturating_add(1); // Not one picture as a whole: the rows drawn one under the other from the start may be. if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) { run = drawn_in_sequence(&ops, i, &xobjects, doc); j = run_end(&ops, i, run.len()); + scanned = scanned.saturating_add(run.len().saturating_mul(2)).saturating_add(2); } let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else { // Not a run: keep this op and look again from the next one. @@ -304,6 +327,8 @@ pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, pro #[cfg(test)] mod tests { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + use super::*; #[test] @@ -318,4 +343,34 @@ mod tests { ]; assert!(merge(&Document::new_empty(), &run, 3).is_none()); } + + /// Many images drawn on top of each other are not one picture; looking for runs among them reads + /// a bounded number of groups per operator, not the rest of the page from every group. + #[test] + fn rejected_runs_read_a_bounded_number_of_groups() { + let mut doc = Document::new_empty(); + let mut image = Dict::new(); + for (k, v) in + [("Subtype", Object::name("Image")), ("Width", Object::Int(1)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))] + { + image.set(k.as_bytes().to_vec(), v); + } + image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0]))); + let groups = 4000; + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(groups).as_bytes()))); + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(image)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut page = Dict::new(); + page.set(b"Contents".to_vec(), Object::Ref(content)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + let page = doc.add(Object::Dict(page)); + GROUP_READS.with(|c| c.set(0)); + let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true); + assert_eq!(merged.ok(), Some(0)); + let reads = GROUP_READS.with(|c| c.get()); + assert!(reads <= 40 * 4 * groups, "{reads} group reads"); + } } From 3e968c8aa24adfb729c6d45a3c98abf5fed07027 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:36:35 +0000 Subject: [PATCH 099/142] chore(a11y, fonts): satisfy clippy in the CID width ranges and a test helper --- crates/a11y/src/tests.rs | 2 +- crates/fonts/src/pdf.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index a24f59edb..ad5747736 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1033,7 +1033,7 @@ fn mark_reads(f: impl FnOnce()) -> usize { /// Page content nesting `depth` unmarked `BMC` blocks around a marked one, with `ops` operators inside. fn deeply_nested_page(doc: &mut Document, depth: usize, ops: usize) -> pdfcraft_cos::Dict { use pdfcraft_cos::{Dict, Stream}; - let mut data = "/Figure << /MCID 0 >> BDC ".repeat(1); + let mut data = "/Figure << /MCID 0 >> BDC ".to_string(); data.push_str(&"/X BMC ".repeat(depth)); data.push_str(&"0 0 1 1 re f ".repeat(ops)); data.push_str(&"EMC ".repeat(depth + 1)); diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 4a118a07d..3ddd16a9b 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -714,10 +714,10 @@ impl Metrics { i += 2; } Some(c1) => { - if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) { - if ranges.len() < MAX_CMAP_ENTRIES { - ranges.push((c0, c1 as u32, v)); - } + if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) + && ranges.len() < MAX_CMAP_ENTRIES + { + ranges.push((c0, c1 as u32, v)); } i += 3; } From f42fb0ad49bc729fbfa8ec492dae592f8ec89bed Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:43:01 +0000 Subject: [PATCH 100/142] fix(forms): a deleted field leaves the calculation order Deleting a field took it off its pages and out of the field tree but left its entry in the form's /CO array. That reference kept the field dictionary, its value and its widgets reachable, so a full save after applying redactions over the field still wrote the redacted value. --- crates/forms/src/author.rs | 25 +++++++++++++++++ crates/redact/src/tests.rs | 1 + .../redact/src/tests/surviving_references.rs | 28 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 crates/redact/src/tests/surviving_references.rs diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index f5178a687..544d0a7f8 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1280,6 +1280,31 @@ pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { } } } + // Out of the calculation order too: a `/CO` entry would keep the field, its value and its + // widgets reachable for a full save. + drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) +} + +/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or +/// by reference. +fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> { + let Some(root) = doc.root() else { return Ok(()) }; + let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; + let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) }; + let Some(co_obj) = af.get(b"CO").cloned() else { return Ok(()) }; + let co = doc.resolve(&co_obj).as_array().cloned().unwrap_or_default(); + let kept: Vec = co.iter().filter(|o| !o.as_ref().is_some_and(&gone)).cloned().collect(); + if kept.len() == co.len() { + return Ok(()); + } + match (co_obj.as_ref(), af_obj.as_ref()) { + (Some(r), _) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), + (_, Some(af_ref)) => doc.update_dict(af_ref, |d| d.set(b"CO".to_vec(), Object::Array(kept)))?, + (_, None) => { + af.set(b"CO".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + } Ok(()) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index a9902495d..52d80eb26 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2462,6 +2462,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } mod property_retirement; +mod surviving_references; /// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still /// draws only the one immediately before it (LASTARR, 7 glyphs, 35 pt wide from x = 10) -- not diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs new file mode 100644 index 000000000..cfe6d87bb --- /dev/null +++ b/crates/redact/src/tests/surviving_references.rs @@ -0,0 +1,28 @@ +//! Redacted content must leave the file: no other reference may keep it reachable by a full save. + +use super::*; + +/// Whether `secret` survives a full save of `doc`, in the file's bytes or in a decoded stream. +fn full_save_holds(doc: &Document, secret: &[u8]) -> bool { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let found = |data: &[u8]| data.windows(secret.len()).any(|w| w == secret); + if found(&bytes) { + return true; + } + let saved = Document::open(Arc::new(bytes)).unwrap(); + saved.scan_objects().into_iter().any(|(_, o)| matches!(&*o, Object::Stream(s) if s.decoded().is_ok_and(|d| found(&d)))) +} + +#[test] +fn a_redacted_field_listed_in_the_calculation_order_leaves_the_saved_file() { + let mut doc = one_page(b"", "", vec![]); + pdfcraft_forms::add_field(&mut doc, 0, [10.0, 50.0, 100.0, 70.0], &pdfcraft_forms::NewField::Text { multiline: false }, Some("ssn")).unwrap(); + pdfcraft_forms::set_value(&mut doc, "ssn", &pdfcraft_forms::FieldValue::Text("SSN-078-05-1120".into())).unwrap(); + let field = pdfcraft_forms::fields(&doc).swap_remove(0).obj; + let root = doc.root().unwrap(); + let af = doc.get(root).as_dict().unwrap().reference(b"AcroForm").unwrap(); + doc.update_dict(af, |d| d.set(b"CO".to_vec(), Object::Array(vec![Object::Ref(field)]))).unwrap(); + mark(&mut doc, 0, &[[0.0, 40.0, 120.0, 80.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().fields, 1); + assert!(!full_save_holds(&doc, b"SSN-078-05-1120"), "the field's value is still reachable"); +} From 6ca4431b50a21760c7c9c9378b9d01a05acf140f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:23 +0000 Subject: [PATCH 101/142] fix(redact): check every input content stream, whatever it calls itself Both checks for text left under applied redaction marks (redaction's own verify pass and the engine's renderer cross-check) skipped any page content stream whose dictionary said /PCMark /Redaction, so that the overlay's label would not count. An input file could put that label on its own content stream and keep it out of both checks. Applying redactions now records the overlay streams it adds (stamp returns the new stream), and only those are skipped. --- crates/edit/src/lib.rs | 15 +++++++++------ crates/engine/src/lib.rs | 15 ++++++++++----- crates/engine/src/tests.rs | 34 +++++++++++++++++++++++++++++++++- crates/redact/src/lib.rs | 16 ++++++++-------- 4 files changed, 60 insertions(+), 20 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index d3db00fe3..bef515f98 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -17,7 +17,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, Object, Stream}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; #[derive(Debug, thiserror::Error, PartialEq)] @@ -601,7 +601,7 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation { /// Put a mark's content on a page: behind (with the behind-marks at the start) or on top /// (appended, after wrapping the original content in q/Q). fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec, behind: bool) -> Result<(), EditError> { - place_tagged(doc, page, kind.tag(), content, behind) + place_tagged(doc, page, kind.tag(), content, behind).map(drop) } /// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the @@ -609,7 +609,8 @@ fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, conten /// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of /// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content /// sequences are closed at the end too, so hidden optional content cannot hide the new mark. -fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result<(), EditError> { +/// Returns the new content stream. +fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result { let mut list = contents(doc, page)?; // The behind-marks at the start: backgrounds in the order added, then watermarks. let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()]; @@ -625,7 +626,8 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont Isolation::Unknown { unclosed, marked } => (behind_marks < list.len()).then_some((behind_marks, unclosed, marked)), } }; - let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content)))); + let mark_ref = doc.add(Object::Stream(Stream::flate(tagged(tag), &content))); + let mark = Object::Ref(mark_ref); if behind { // Right after the backgrounds already there, so a new background is painted over the // older ones (#3591) and a watermark over all of them. Not after the watermarks: on a @@ -646,7 +648,7 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont list.push(mark); } doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; - Ok(()) + Ok(mark_ref) } /// Put content tagged `tag` first inside the q/Q wrapper, so it applies to the page's original @@ -664,7 +666,8 @@ fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Page, tag: &s /// Draw `content` on top of a page (0-based) as permanent page content, with standard Helvetica /// available as `/PCHelv`. The stream is tagged `tag` (not a mark kind, so Remove never takes /// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes. -pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec) -> Result<(), EditError> { +/// Returns the new content stream. +pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec) -> Result { let all = page_list(doc); check(&[page], all.len())?; add_resources(doc, &all[page], None, Some(&content))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 817eb79a9..32d3a1f15 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -989,12 +989,13 @@ fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { /// that it reads differently), a mark drawn over the text as displayed lies over glyphs the /// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is /// what the user saw when placing the marks, so it is asked. `after` is written as a save would -/// write it, without the marks' own overlay (the box and its label are drawn on purpose), its +/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its /// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps /// the document as it was. fn shown_under_redactions( after: &pdfcraft_cos::Document, marks: &[pdfcraft_redact::Mark], + overlays: &[pdfcraft_cos::ObjRef], pages: &[pdfcraft_render::PageInfo], password: Option<&str>, ) -> Result<(), EditError> { @@ -1011,8 +1012,7 @@ fn shown_under_redactions( for &pi in &marked { let Some(page) = model.get(pi) else { continue }; let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; - let kept: Vec = - list.into_iter().filter(|o| shown.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")).collect(); + let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; } let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; @@ -2356,6 +2356,9 @@ struct EditCtx { /// Fields the edit renamed to keep imported names separate (old → new), for the repair /// log and the undo label. renamed_fields: Vec<(String, String)>, + /// The overlay streams the edit's applied redactions added, which alone the renderer's check + /// leaves out. + redaction_overlays: Vec, } impl EditCtx { @@ -2382,6 +2385,7 @@ impl EditCtx { seed, count: 0, renamed_fields: Vec::new(), + redaction_overlays: Vec::new(), } } @@ -2736,7 +2740,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; } Edit::ApplyRedactions { pages } => { - pdfcraft_redact::apply(doc, pages.as_deref())?; + cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; } Edit::ClearRedactions => { pdfcraft_redact::clear_marks(doc, None)?; @@ -3798,6 +3802,7 @@ impl Session { cx.js = Some(js::JsRunner::new(&next, &name)); } cx.renamed_fields.clear(); + cx.redaction_overlays.clear(); // `next` is a copy: if the edit fails or crashes, the document is unchanged. guard(|| run_edit(&mut next, &edit, &mut cx)) .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; @@ -3806,7 +3811,7 @@ impl Session { if let Edit::ApplyRedactions { pages } = &edit { let marks: Vec = pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); - guard(|| shown_under_redactions(&next, &marks, &doc.info.pages, doc.password.as_deref())) + guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; } // Renamed fields are said, not slipped in: a repair-log note (with the caveat that diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3d347e649..958204bc5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2253,12 +2253,17 @@ fn setting_the_calculation_order_recalculates_and_undoes() { /// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what /// the page's /Resources holds. fn one_line_page(resources: &str, body: &str) -> Vec { + one_line_page_with(resources, "", body) +} + +/// [`one_line_page`] with `stream_entries` added to the content stream's dictionary. +fn one_line_page_with(resources: &str, stream_entries: &str, body: &str) -> Vec { let objs = [ "<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(), format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> /Contents 5 0 R >>"), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), - format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + format!("<< /Length {} {stream_entries} >>\nstream\n{body}\nendstream", body.len()), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); @@ -2316,6 +2321,33 @@ fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() { assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}"); } +/// Only the overlay a redaction adds is left out of the renderer's check. A page whose own content +/// stream calls itself a redaction overlay (`/PCMark /Redaction`) is checked like any other. +#[test] +fn a_content_stream_labelled_as_a_redaction_overlay_is_still_checked() { + let line = "Public words then SECRET42 and more"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let forged = one_line_page_with( + "/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", + "/PCMark /Redaction", + &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET"), + ); + let id = s.open("forged.pdf", None, Arc::new(forged), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET42"); + let applied = s.apply(id, Edit::ApplyRedactions { pages: None }); + if applied.is_ok() { + assert!(!page_texts(&s, id).concat().contains("SECRET42"), "the page still shows the redacted word"); + let saved = s.save_bytes(id).unwrap(); + let held = |d: &[u8]| d.windows(8).any(|w| w == b"SECRET42"); + assert!(!held(&saved), "the saved file holds the redacted word"); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!( + !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), + "a saved stream holds the redacted word" + ); + } +} + #[test] fn redaction_marks_apply_for_good_and_undo() { let (mut s, id) = session_with(2); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 968773b91..97d5bcd9f 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -143,6 +143,10 @@ pub struct Report { /// The form was an AcroForm+XFA form and its `/XFA` packets (which still held redacted field /// values, and from which a relayout could rebuild them) were removed. pub xfa_removed: bool, + /// The content streams this apply added to draw the marks' boxes and labels. They are the + /// only streams a check for text left under the marks may skip: an input stream labelled + /// like an overlay (`/PCMark /Redaction`) is checked like any other. + pub overlays: Vec, } fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { @@ -576,7 +580,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); let (list, data) = page_streams(doc, &page.dict, pi)?; - // The overlay stream (last) draws text of its own: the overlay label is allowed. - let original: Vec> = list - .iter() - .zip(data) - .filter(|(o, _)| doc.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")) - .map(|(_, d)| d) - .collect(); + // The overlay stream draws text of its own: the overlay label is allowed. + let original: Vec> = + list.iter().zip(data).filter(|(o, _)| !o.as_ref().is_some_and(|r| report.overlays.contains(&r))).map(|(_, d)| d).collect(); let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut scratch = Report::default(); let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch); From 22aa6d5f9afb7339a19fa55b792b96f7c7910c37 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:50:59 +0000 Subject: [PATCH 102/142] fix(redact): place glyphs with the inherited and ExtGState-set text state The redaction interpreter started every form XObject with an empty text state (no font, size 0, no spacing) although a form inherits its caller's graphics state, and it ignored the /Font entry of an ExtGState set by gs. Text shown that way was placed at the wrong positions, so removal and the verify pass could both miss glyphs under a mark. Forms now start from the caller's state, and gs sets the font and size as Tf does. The engine's renderer cross-check test used the gs case as its example of interpreter/renderer disagreement; it now runs the check directly on an unredacted page, which also covers an input stream labelled as a redaction overlay. --- crates/engine/src/lib.rs | 2 +- crates/engine/src/tests.rs | 78 +++++++++++++-------------- crates/redact/src/interp.rs | 51 ++++++++++++++++-- crates/redact/src/tests.rs | 1 + crates/redact/src/tests/text_state.rs | 27 ++++++++++ 5 files changed, 111 insertions(+), 48 deletions(-) create mode 100644 crates/redact/src/tests/text_state.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 32d3a1f15..f257865d3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -985,7 +985,7 @@ fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { /// /// Redaction decides what lies under a mark from the positions its own interpreter computes, and /// its verify pass reuses them. Where they differ from the renderer's (a standard font without -/// /Widths, whose advances the interpreter approximates; a CMap, or a font set outside `Tf`, +/// /Widths, whose advances the interpreter approximates; a CMap /// that it reads differently), a mark drawn over the text as displayed lies over glyphs the /// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is /// what the user saw when placing the marks, so it is asked. `after` is written as a save would diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 958204bc5..8937cf678 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2294,58 +2294,52 @@ fn mark_shown_text(s: &mut Session, id: DocId, needle: &str) { s.apply(id, mark).unwrap(); } -/// Redaction removes text by the positions its own interpreter works out. Here the font and size -/// come from an ExtGState /Font entry (`gs`), which the renderer honours and the interpreter does -/// not model, so it places no glyph under the mark the user drew over the word as displayed: it -/// used to report success and leave the word in the file. The renderer is asked now, and the edit -/// is refused. +/// Redaction removes text by the positions its own interpreter works out, and the renderer has +/// the last word: where the two disagree, a page that still shows a glyph mostly under a mark +/// fails the edit. The check is run here on a page nothing was removed from, as if the +/// interpreter had placed the word elsewhere. Only the overlay streams an apply added are left +/// out: a content stream of the input labelled like one (`/PCMark /Redaction`) is checked too. #[test] -fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() { +fn the_renderer_check_refuses_text_still_shown_under_a_mark() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page_with("/Font << /F1 4 0 R >>", "/PCMark /Redaction", "BT /F1 16 Tf 20 150 Td (Public words then SECRET42) Tj ET"); + let id = s.open("shown.pdf", None, Arc::new(page), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET42"); + let doc = s.get(id).unwrap(); + let mut cos = doc.editor.as_ref().unwrap().cos.clone(); + let marks = pdfcraft_redact::marks(&cos); + let refused = |cos: &pdfcraft_cos::Document| { + let shown = shown_under_redactions(cos, &marks, &[], &doc.info.pages, None); + matches!(shown, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0) + }; + assert!(refused(&cos)); + // The page's contents as an array, as they are once an apply has added its overlay. + let page = pdfcraft_model::pages(&cos).swap_remove(0); + let contents = page.dict.get(b"Contents").cloned().unwrap(); + cos.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Array(vec![contents]))).unwrap(); + assert!(refused(&cos), "the labelled input stream was not checked"); +} + +/// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer +/// draws it, so a mark drawn over it as displayed takes it out of the file. +#[test] +fn a_word_in_a_font_set_by_an_extgstate_is_redacted() { let line = "Public words then SECRET42 and more"; let mut s = Session::new().with_clock(|| 1_700_000_000); let by_gs = one_line_page("/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET")); let id = s.open("gs.pdf", None, Arc::new(by_gs), None).unwrap(); mark_shown_text(&mut s, id, "SECRET42"); - let refused = s.apply(id, Edit::ApplyRedactions { pages: None }); - assert!(matches!(refused, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0), "{refused:?}"); - // Nothing was changed: the mark is still pending and the page still shows the line. - assert_eq!(s.get(id).unwrap().redaction_marks(), 1); - assert_eq!(page_texts(&s, id), [line]); - - // The same line with its font set by `Tf`: the mark is applied and the word is gone. - let by_tf = one_line_page("/Font << /F1 4 0 R >>", &format!("BT /F1 16 Tf 20 150 Td ({line}) Tj ET")); - let id = s.open("tf.pdf", None, Arc::new(by_tf), None).unwrap(); - mark_shown_text(&mut s, id, "SECRET42"); s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); let shown = page_texts(&s, id).swap_remove(0); assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}"); -} - -/// Only the overlay a redaction adds is left out of the renderer's check. A page whose own content -/// stream calls itself a redaction overlay (`/PCMark /Redaction`) is checked like any other. -#[test] -fn a_content_stream_labelled_as_a_redaction_overlay_is_still_checked() { - let line = "Public words then SECRET42 and more"; - let mut s = Session::new().with_clock(|| 1_700_000_000); - let forged = one_line_page_with( - "/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", - "/PCMark /Redaction", - &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET"), + let saved = s.save_bytes(id).unwrap(); + let held = |d: &[u8]| d.windows(8).any(|w| w == b"SECRET42"); + assert!(!held(&saved), "the saved file holds the redacted word"); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!( + !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), + "a saved stream holds the redacted word" ); - let id = s.open("forged.pdf", None, Arc::new(forged), None).unwrap(); - mark_shown_text(&mut s, id, "SECRET42"); - let applied = s.apply(id, Edit::ApplyRedactions { pages: None }); - if applied.is_ok() { - assert!(!page_texts(&s, id).concat().contains("SECRET42"), "the page still shows the redacted word"); - let saved = s.save_bytes(id).unwrap(); - let held = |d: &[u8]| d.windows(8).any(|w| w == b"SECRET42"); - assert!(!held(&saved), "the saved file holds the redacted word"); - let reopened = pdfcraft_cos::Document::open(saved).unwrap(); - assert!( - !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), - "a saved stream holds the redacted word" - ); - } } #[test] diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index a608568f3..14dedbb34 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -690,6 +690,34 @@ fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Ok(()) } +/// The font, writing direction and size an ExtGState's `/Font [font size]` entry sets, as `Tf` +/// would (ISO 32000-2 §8.4.5); `None` when `name` sets no font. The metrics are cached with the +/// fonts `Tf` names, under a key no resource name can take (a name cannot hold a null byte). +fn extgstate_font( + doc: &Document, + scope: &mut Scope<'_>, + ext_gstates: &Dict, + name: &[u8], + fallback: &Rc, +) -> Option<(Rc, bool, f64)> { + let state = doc.resolve(ext_gstates.get(name)?).as_dict().cloned()?; + let entry = doc.resolve(state.get(b"Font")?).as_array().cloned()?; + let [font, size] = entry.as_slice() else { return None }; + let size = doc.resolve(size).as_f64()?; + let font_dict = doc.resolve(font).as_dict().cloned(); + let vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + let key = [b"\0".as_slice(), name].concat(); + let metrics = match scope.fonts.get(&key) { + Some(m) => m.clone(), + None => { + let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); + scope.fonts.insert(key, m.clone()); + m + } + }; + Some((metrics, vertical, size)) +} + /// One glyph of a shown string. struct Glyph { bytes: std::ops::Range, @@ -700,11 +728,9 @@ struct Glyph { /// Process a scope's content streams (state flows from one stream to the next). pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, ctm: Matrix) -> Output { - let mut out = Output::default(); - let fallback = Rc::new(Metrics::fallback()); - let mut gs = Gs { + let gs = Gs { ctm, - font: fallback.clone(), + font: Rc::new(Metrics::fallback()), vertical: false, size: 0.0, char_spacing: 0.0, @@ -714,9 +740,19 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< leading: 0.0, rise: 0.0, }; + process_from(doc, scope, streams, resources, gs) +} + +/// [`process`] from graphics state `gs`: a form XObject starts with its caller's, text state +/// included (ISO 32000-2 §8.10.1), so text it shows without setting a font of its own is placed +/// with the caller's font, size and spacing, as the renderer places it. +fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, mut gs: Gs) -> Output { + let mut out = Output::default(); + let fallback = Rc::new(Metrics::fallback()); let mut stack: Vec = Vec::new(); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let fonts_res = res_dict(doc, resources, b"Font"); + let ext_gstates = res_dict(doc, resources, b"ExtGState"); let mut xobjects = res_dict(doc, resources, b"XObject"); // The current path: its operators, its bounding box in user space, and whether it clips. let mut path: Vec = Vec::new(); @@ -865,6 +901,11 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< }; } } + b"gs" => { + if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &ext_gstates, n, &fallback)) { + (gs.font, gs.vertical, gs.size) = (font, vertical, size); + } + } b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, @@ -1223,7 +1264,7 @@ fn xobject( let res = own.clone().unwrap_or_else(|| resources.clone()); let saved_fonts = std::mem::take(&mut scope.fonts); scope.depth += 1; - let inner = process(doc, scope, std::slice::from_ref(&data), &res, fctm); + let inner = process_from(doc, scope, std::slice::from_ref(&data), &res, Gs { ctm: fctm, ..gs.clone() }); scope.depth -= 1; scope.fonts = saved_fonts; out.residue += inner.residue; diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 52d80eb26..882cc50ae 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2463,6 +2463,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { mod property_retirement; mod surviving_references; +mod text_state; /// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still /// draws only the one immediately before it (LASTARR, 7 glyphs, 35 pt wide from x = 10) -- not diff --git a/crates/redact/src/tests/text_state.rs b/crates/redact/src/tests/text_state.rs new file mode 100644 index 000000000..4e888f262 --- /dev/null +++ b/crates/redact/src/tests/text_state.rs @@ -0,0 +1,27 @@ +//! Glyphs are placed with the whole text state, wherever it was set. + +use super::*; + +const SECRET_AT_10_100: [f64; 4] = [5.0, 95.0, 45.0, 110.0]; + +#[test] +fn a_font_set_by_an_extgstate_places_its_glyphs() { + let mut doc = one_page(b"BT /GS1 gs 10 100 Td (SECRET) Tj ET", "/ExtGState << /GS1 << /Type /ExtGState /Font [5 0 R 10] >> >>", vec![]); + // "ET" spans x 30–40. + assert_eq!(under(&mut doc, 0, &[[30.0, 95.0, 40.0, 110.0]]), 2); + mark(&mut doc, 0, &[SECRET_AT_10_100], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + assert!(!content(&doc, 0).contains("SECRET")); +} + +#[test] +fn a_form_inherits_the_text_state_of_the_page_that_draws_it() { + // The form shows text with the font, size and spacing the page set before `Do`. + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT 10 100 Td (SECRET) Tj ET"); + let mut doc = one_page(b"/F1 10 Tf 1 Tc /Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + // Glyphs 5 pt wide, 1 pt apart: the last one spans x 40–45. + assert_eq!(under(&mut doc, 0, &[[40.0, 95.0, 46.0, 110.0]]), 1); + mark(&mut doc, 0, &[SECRET_AT_10_100], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.glyphs, r.forms_rewritten), (6, 1), "{r:?}"); +} From be64a60c876402f43ffef893f1fd87ac77786981 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:52:12 +0000 Subject: [PATCH 103/142] fix(redact): unlink removed XObjects from surviving forms' resources After applying redactions, removed images and forms were unlinked from page-tree resource dictionaries but not from the resources of forms the page still draws. A form sharing the page's original resource dictionary (a common producer layout) kept the removed image reachable, so a full save wrote it. The walk over surviving forms now drops XObject entries for removed objects that the form's own content does not draw, and fails closed when that cannot be determined. --- crates/redact/src/interp.rs | 34 +++++++++++++--- .../redact/src/tests/surviving_references.rs | 40 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 14dedbb34..fca3a1da8 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -529,8 +529,9 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_prope } } // A surviving form can own the same old resource dictionary as a rewritten page. Its - // unused descriptions remain reachable too, even after every page holder is cleaned. - unlink_form_properties(doc, &holder_nodes, retired_properties)?; + // unused descriptions and doomed XObjects remain reachable too, even after every page + // holder is cleaned. + unlink_from_forms(doc, &holder_nodes, &doomed, retired_properties)?; Ok(()) } @@ -567,8 +568,11 @@ fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], c Ok(pruned) } -fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Object]) -> Result<(), CosError> { - if retired.is_empty() { +/// Unlink retired properties and `doomed` XObjects from the resources of the forms reachable from +/// the page-tree `holders`, each unless the form's own content (or a form inheriting its +/// resources) still uses the name. Fails closed when that cannot be determined. +fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet, retired: &[Object]) -> Result<(), CosError> { + if retired.is_empty() && doomed.is_empty() { return Ok(()); } let mut pending: Vec = holders @@ -593,7 +597,27 @@ fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Obj let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; let names: Vec> = res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect(); - if !names.is_empty() && prune_retired_properties(doc, &mut res, &names, &[vec![form.decoded()?]])? { + let mut xo = res_dict(doc, &res, b"XObject"); + let doomed_names: Vec> = + xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); + if names.is_empty() && doomed_names.is_empty() { + pending.push(res); + continue; + } + let data = form.decoded()?; + let mut pruned = false; + for name in doomed_names { + match content_draws(doc, std::slice::from_ref(&data), &xo, &name, 0, &mut HashSet::new()) { + Some(true) => {} + Some(false) => pruned |= xo.remove(&name).is_some(), + None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }), + } + } + if pruned { + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + pruned |= prune_retired_properties(doc, &mut res, &names, &[vec![data]])?; + if pruned { doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; } pending.push(res); diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index cfe6d87bb..2c8bdb09c 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -26,3 +26,43 @@ fn a_redacted_field_listed_in_the_calculation_order_leaves_the_saved_file() { assert_eq!(apply(&mut doc, None).unwrap().fields, 1); assert!(!full_save_holds(&doc, b"SSN-078-05-1120"), "the field's value is still reachable"); } + +/// A surviving form shares the page's resource dictionary, which lists the image the mark +/// removes; the form never draws it. +#[test] +fn a_redacted_image_does_not_survive_in_a_surviving_forms_resources() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 7 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Font << /F1 5 0 R >> /XObject << /Im 8 0 R /Fm 9 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 7 0 R", b"BT /F1 10 Tf 10 200 Td (Public) Tj ET"), + ]); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(content(&doc, 0).contains("Do"), "the form is still drawn"); + assert!(!full_save_holds(&doc, b"IMAGESECRET"), "the removed image is still reachable"); +} + +/// The same form drawing the image itself keeps it: the page still shows it there. +#[test] +fn an_image_a_surviving_form_draws_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 7 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Font << /F1 5 0 R >> /XObject << /Im 8 0 R /Fm 9 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 7 0 R", b"q 50 0 0 50 200 200 cm /Im Do Q"), + ]); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(full_save_holds(&doc, b"IMAGESECRET"), "the image the form still draws outside the mark"); +} From cccd435a5308bd628976e0f0d9923f1eebf90e36 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:53:24 +0000 Subject: [PATCH 104/142] fix(redact): bound image decoding by the declared raster size Clearing the pixels of a partly covered image decoded its stream with the global 1 GiB allowance and only then cut it to the raster length its Width, Height and bit depth declare. A small Flate stream could make every such image allocate and inflate far more than it can use. Decoding now stops at twice the raster plus a byte per row (filter-chain and predictor overhead) and a little slack; an image past that is removed whole, as an undecodable one already is. --- crates/redact/src/image.rs | 6 +++++- crates/redact/src/tests.rs | 1 + crates/redact/src/tests/limits.rs | 25 +++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 crates/redact/src/tests/limits.rs diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 9a19ba2f0..601700f25 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -77,7 +77,11 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] let bytes = row.checked_mul(h)?; let row_bits = row.checked_mul(8)?; bytes.checked_mul(8)?; - let mut data = s.decoded().ok()?; + // Decode no further than the raster can need: a filter stage may legitimately produce up to + // twice it (ASCIIHex text under Flate) plus a PNG predictor's byte per row. Data past that + // can only be a decompression bomb; the image is then removed whole. + let allowance = bytes.checked_add(h)?.checked_mul(2)?.checked_add(4096)?; + let mut data = s.decoded_within(allowance).ok()?; if data.len() < bytes { return None; } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 882cc50ae..94081f9e3 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2461,6 +2461,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } } +mod limits; mod property_retirement; mod surviving_references; mod text_state; diff --git a/crates/redact/src/tests/limits.rs b/crates/redact/src/tests/limits.rs new file mode 100644 index 000000000..6bee96aed --- /dev/null +++ b/crates/redact/src/tests/limits.rs @@ -0,0 +1,25 @@ +//! Work and memory stay bounded by what the page declares, whatever the file holds. + +use super::*; + +/// A half-covered 4×1 grey image whose Flate data decodes to `decoded`. +fn half_covered_image(decoded: &[u8]) -> Report { + let dict = "/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + let mut doc = one_page(b"q 40 0 0 10 100 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, b"")]); + let Object::Stream(s) = &*doc.get(ObjRef::new(7, 0)) else { panic!() }; + doc.set(ObjRef::new(7, 0), Object::Stream(Stream::flate(s.dict.clone(), decoded))); + mark(&mut doc, 0, &[[95.0, 95.0, 120.0, 115.0]], ""); + apply(&mut doc, None).unwrap() +} + +#[test] +fn an_image_decoding_far_past_its_raster_is_removed_without_decoding_it_all() { + let r = half_covered_image(&[255; 4]); + assert_eq!((r.images_removed, r.images_cleared), (0, 1), "{r:?}"); + // 4 bytes of raster, then 4 MiB the image never uses: decoding stops at a bound derived from + // the raster, and the image goes whole. + let mut bomb = vec![255; 4]; + bomb.resize(4 << 20, 0); + let r = half_covered_image(&bomb); + assert_eq!((r.images_removed, r.images_cleared), (1, 0), "{r:?}"); +} From 2cdb39503ef303b5cc66703abda470d044896690 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:55:22 +0000 Subject: [PATCH 105/142] fix(redact): compare marked content without a copy per enclosing MCID Finding which marked content a redaction changed copied every operator into the buffer of each MCID enclosing it, so content nested n deep was held n times: a modest stream of nested BDCs could exhaust memory. Each operator inside marked content is now serialized once, and each MCID keeps the ranges its sequences span. The bytes compared are capped; MCIDs past the cap count as changed, so their descriptions are cleared rather than kept. --- crates/redact/src/tags.rs | 88 +++++++++++++++++++++++++------ crates/redact/src/tests/limits.rs | 30 +++++++++++ 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index ab5735e06..854881060 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -6,15 +6,49 @@ //! references to marked content that is now empty are dropped from the element. use std::collections::{HashMap, HashSet}; +use std::ops::Range; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::RedactError; -/// Each MCID's operators (serialized), and whether anything is still drawn inside it. -fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap, bool)> { - let mut out: HashMap, bool)> = HashMap::new(); - let mut stack: Vec> = Vec::new(); +/// The marked content of one content stream. Every operator inside some MCID is serialized once +/// into `ops`, and each MCID holds the ranges of `ops` its sequences span (marked-content +/// operators are not written, so a sequence is one range however deeply it nests), with whether +/// anything is drawn inside it. Copying the operators into every enclosing MCID instead would +/// hold `depth` copies of nested content. +#[derive(Default)] +struct Marked { + ops: Vec, + ids: HashMap>, bool)>, +} + +impl Marked { + /// The bytes of `ranges`, which are ranges of `ops`. + fn bytes<'a>(&'a self, ranges: &'a [Range]) -> impl Iterator + 'a { + ranges.iter().flat_map(|r| self.ops.get(r.clone()).unwrap_or_default()) + } +} + +/// Most bytes [`touched`] compares for one stream. Nested sequences share their operators, so the +/// comparisons can add up to the content's size times its nesting depth; past this, the MCIDs +/// left are taken as changed (their descriptions go) rather than compared. +const MAX_COMPARED: usize = 1 << 28; + +fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> Marked { + let mut out = Marked::default(); + // Open sequences: the MCID with where its content starts in `ops` and the drawing operators + // written before it, or `None` for a sequence without one. + let mut stack: Vec> = Vec::new(); + let (mut open_ids, mut drawn) = (0usize, 0usize); + let close = |out: &mut Marked, open: Option<(i64, usize, usize)>, drawn: usize| { + if let Some((id, start, drawn_before)) = open { + let end = out.ops.len(); + let e = out.ids.entry(id).or_default(); + e.0.push(start..end); + e.1 |= drawn > drawn_before; + } + }; for op in pdfcraft_content::parse(data).ops { match op.op.as_slice() { b"BDC" => { @@ -28,9 +62,10 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap None, }; if let Some(id) = id { - out.entry(id).or_default(); + out.ids.entry(id).or_default(); + open_ids += 1; } - stack.push(id); + stack.push(id.map(|id| (id, out.ops.len(), drawn))); continue; } b"BMC" => { @@ -38,11 +73,17 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap { - stack.pop(); + if let Some(open) = stack.pop() { + open_ids = open_ids.saturating_sub(usize::from(open.is_some())); + close(&mut out, open, drawn); + } continue; } _ => {} } + if open_ids == 0 { + continue; + } let draws = match op.op.as_slice() { b"Tj" | b"'" | b"\"" => op.operands.last().and_then(Object::as_string).is_some_and(|s| !s.bytes.is_empty()), b"TJ" => { @@ -51,13 +92,12 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap true, _ => false, }; - let mut bytes = Vec::new(); - pdfcraft_content::write_op(&op, &mut bytes); - for id in stack.iter().flatten() { - let e = out.entry(*id).or_default(); - e.0.extend_from_slice(&bytes); - e.1 |= draws; - } + pdfcraft_content::write_op(&op, &mut out.ops); + drawn += usize::from(draws); + } + // Sequences the stream leaves open run to its end. + while let Some(open) = stack.pop() { + close(&mut out, open, drawn); } out } @@ -67,10 +107,17 @@ pub(crate) fn touched(doc: &Document, resources: &Dict, before: &[u8], after: &[ let (b, a) = (by_mcid(doc, resources, before), by_mcid(doc, resources, after)); let mut changed = HashSet::new(); let mut empty = HashSet::new(); - for (id, (ops, _)) in &b { - match a.get(id) { + let mut budget = MAX_COMPARED; + let len = |ranges: &[Range]| ranges.iter().map(|r| r.len()).fold(0usize, usize::saturating_add); + for (id, (old, _)) in &b.ids { + match a.ids.get(id) { Some((new, draws)) => { - if new != ops { + let n = len(old); + let same = n == len(new) && n <= budget && { + budget -= n; + b.bytes(old).eq(a.bytes(new)) + }; + if !same { changed.insert(*id); } if !draws { @@ -413,3 +460,10 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet } Ok(n) } + +/// Bytes held for comparing marked content, for tests that bound it. +#[cfg(test)] +pub(crate) fn retained_for_comparison(doc: &Document, resources: &Dict, data: &[u8]) -> usize { + let marked = by_mcid(doc, resources, data); + marked.ops.len() + marked.ids.values().map(|(ranges, _)| ranges.len() * std::mem::size_of::>()).sum::() +} diff --git a/crates/redact/src/tests/limits.rs b/crates/redact/src/tests/limits.rs index 6bee96aed..8519e0860 100644 --- a/crates/redact/src/tests/limits.rs +++ b/crates/redact/src/tests/limits.rs @@ -23,3 +23,33 @@ fn an_image_decoding_far_past_its_raster_is_removed_without_decoding_it_all() { let r = half_covered_image(&bomb); assert_eq!((r.images_removed, r.images_cleared), (1, 0), "{r:?}"); } + +/// Marked content nested `depth` deep, each level with its own MCID, around `depth` drawing +/// operators. +fn nested_marked_content(depth: usize) -> Vec { + let mut data = Vec::new(); + for id in 0..depth { + data.extend_from_slice(format!("/P <> BDC\n").as_bytes()); + } + data.extend(b"0 0 1 1 re f\n".repeat(depth)); + data.extend(b"EMC\n".repeat(depth)); + data +} + +#[test] +fn comparing_deeply_nested_marked_content_holds_each_operator_once() { + let doc = one_page(b"", "", vec![]); + let data = nested_marked_content(2000); + let held = tags::retained_for_comparison(&doc, &Dict::new(), &data); + assert!(held <= 2 * data.len(), "{held} bytes held for {} bytes of content", data.len()); +} + +#[test] +fn a_change_inside_nested_marked_content_touches_every_enclosing_mcid() { + let doc = one_page(b"", "", vec![]); + let before = b"/P <> BDC /P <> BDC BT (A) Tj ET EMC 0 0 1 1 re f EMC /P <> BDC BT (B) Tj ET EMC"; + let after = b"/P <> BDC /P <> BDC BT ET EMC 0 0 1 1 re f EMC /P <> BDC BT (B) Tj ET EMC"; + let (changed, empty) = tags::touched(&doc, &Dict::new(), before, after); + assert_eq!(changed, HashSet::from([0, 1])); + assert_eq!(empty, HashSet::from([1])); +} From 7db37911ed7f22f623f5a9416aa1e83ebe053adc Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:57:39 +0000 Subject: [PATCH 106/142] fix(redact): clear descriptions above removed content and annotations Structure cleanup cleared alternate text, actual text and expansions only on the element that directly owned changed marked content, and an element that lost an annotation reference kept its descriptions unless it lost every kid. An ancestor's /ActualText covering a redacted span, or a link element's /Alt repeating the address of a removed link, stayed in the saved file. Elements that lose an annotation now always lose their descriptions, and every ancestor of an affected element (found through both /P and the tree's /K, which may disagree) loses its own. An element written in place above one with a description to clear refuses the operation, as other in-place cases already do. --- crates/redact/src/tags.rs | 135 ++++++++++++++---- crates/redact/src/tests.rs | 1 + crates/redact/src/tests/descriptions.rs | 64 +++++++++ .../redact/src/tests/surviving_references.rs | 2 +- 4 files changed, 177 insertions(+), 25 deletions(-) create mode 100644 crates/redact/src/tests/descriptions.rs diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index 854881060..60c158b18 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -183,20 +183,21 @@ pub(crate) fn clean_within( // also from the parent tree and the ID tree: one listed only there still owns marked content // (#3302). The stack pops /K first, so an element reached through /K gets the page it inherits // from its ancestors before a tree reaches it without one. - let mut todo: Vec<(Object, Option)> = - [&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None)).collect(); + let mut todo: Vec<(Object, Option, Above)> = + [&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None, Above::default())).collect(); let mut seen = HashSet::new(); let mut pages = HashMap::new(); + let mut parents = Parents::new(); let mut hits: Vec<(ObjRef, Dict, Option, bool)> = Vec::new(); // Whether marked content `x` (a kid on page `pg`) is one this cleanup must change. let touches = |doc: &Document, x: &Object, pg: Option| { mcid_of(doc, x, pg, page, stream).is_some_and(|m| stream.is_some() || changed.contains(&m) || empty.contains(&m)) }; - while let Some((k, pg)) = todo.pop() { + while let Some((k, pg, above)) = todo.pop() { match k { - Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg))), + Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg, above))), // A number or name tree node written in place. - Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))), + Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg, above))), // An element written in place: its kids are followed, but if it owns marked content // this cleanup changes, only rewriting what holds it could change it. Object::Dict(d) => { @@ -208,9 +209,11 @@ pub(crate) fn clean_within( if kids.iter().any(|x| touches(doc, x, pg)) { return Err(RedactError::Tags); } - todo.extend(kids.into_iter().map(|x| (x, pg))); + let above = above.through(&d); + todo.extend(kids.into_iter().map(|x| (x, pg, above))); } Object::Ref(r) => { + parents.record(r, above); if !seen.insert(r) { continue; } @@ -219,13 +222,13 @@ pub(crate) fn clean_within( } let object = doc.get(r); if let Object::Array(a) = &*object { - todo.extend(a.iter().cloned().map(|x| (x, pg))); + todo.extend(a.iter().cloned().map(|x| (x, pg, above))); continue; } let Some(d) = object.as_dict().cloned() else { continue }; if !d.contains(b"S") { // A parent tree or ID tree node: its values may be elements. - todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))); + todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg, above))); continue; } let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) { @@ -238,11 +241,12 @@ pub(crate) fn clean_within( if sensitive || remap { hits.push((r, d.clone(), pg, sensitive)); } - todo.extend(kids.into_iter().map(|x| (x, pg))); + todo.extend(kids.into_iter().map(|x| (x, pg, Above::element(r)))); } _ => {} } } + let sensitive: Vec = hits.iter().filter(|h| h.3).map(|h| h.0).collect(); let mut n = 0; for (r, d, pg, sensitive) in hits { let kids = d.get(b"K").map(|k| doc.resolve(k)); @@ -291,9 +295,88 @@ pub(crate) fn clean_within( })?; n += usize::from(sensitive && doc.get(r).as_dict() != Some(&d)); } + // An element above may describe the changed content too (`/ActualText` on a span's parent + // covers the span, §14.9.4). + n += clear_ancestors(doc, &sensitive, &parents, limit)?; Ok(n) } +/// Where the walk reached an object from: the nearest element above it that is an object of its +/// own, and whether an element written in place between them carries a description (which only +/// rewriting the object holding it could remove). +#[derive(Clone, Copy, Default)] +struct Above { + element: Option, + described_in_place: bool, +} + +impl Above { + fn element(r: ObjRef) -> Self { + Self { element: Some(r), described_in_place: false } + } + + /// Below an element written in place. + fn through(self, d: &Dict) -> Self { + Self { described_in_place: self.described_in_place || DESCRIPTIONS.iter().any(|k| d.contains(k)), ..self } + } +} + +/// The keys of an element that describe its content. +const DESCRIPTIONS: [&[u8]; 3] = [b"Alt", b"ActualText", b"E"]; + +/// Each element's parents as the walk found them: through `/K`, which `/P` may not agree with. +struct Parents(HashMap>); + +impl Parents { + fn new() -> Self { + Self(HashMap::new()) + } + + fn record(&mut self, r: ObjRef, above: Above) { + if above.element.is_some() || above.described_in_place { + self.0.entry(r).or_default().push(above); + } + } +} + +/// Remove the descriptions of every element above `elements`, through both `/P` and the walk's +/// `parents`; returns how many elements changed. [`RedactError::Tags`] when an element written in +/// place above them carries one, or when there are more than `limit` ancestors. +fn clear_ancestors(doc: &mut Document, elements: &[ObjRef], parents: &Parents, limit: usize) -> Result { + let mut todo: Vec = elements.to_vec(); + let mut seen: HashSet = elements.iter().copied().collect(); + let mut cleared = 0; + while let Some(r) = todo.pop() { + let mut up: Vec = doc.get(r).as_dict().and_then(|d| d.get(b"P")).and_then(Object::as_ref).into_iter().collect(); + for above in parents.0.get(&r).into_iter().flatten() { + if above.described_in_place { + return Err(RedactError::Tags); + } + up.extend(above.element); + } + for a in up { + if !seen.insert(a) { + continue; + } + if seen.len() > limit { + return Err(RedactError::Tags); + } + // The structure tree root (no /S) ends the climb. + let Some(d) = doc.get(a).as_dict().filter(|d| d.contains(b"S")).cloned() else { continue }; + if DESCRIPTIONS.iter().any(|k| d.contains(k)) { + doc.update_dict(a, |e| { + for key in DESCRIPTIONS { + e.remove(key); + } + })?; + cleared += 1; + } + todo.push(a); + } + } + Ok(cleared) +} + /// The page an element inherits (§14.7.4.4): the nearest `/Pg` among its ancestors, following /// `/P`. `Ok(None)` when none has one; [`RedactError::Tags`] when the lookup gives up (more than /// [`MAX_PARENTS`] ancestors, or a loop), which isn't the same as having no page. `pages` keeps @@ -384,12 +467,14 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet }; // Elements are reached from the root's /K, and also from the parent tree and the ID tree: // one listed only there (left behind by an editor, say) still points at its annotation. - let mut todo: Vec = [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).collect(); + let mut todo: Vec<(Object, Above)> = + [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, Above::default())).collect(); let mut seen = HashSet::new(); + let mut parents = Parents::new(); let mut hits: Vec = Vec::new(); - while let Some(k) = todo.pop() { + while let Some((k, above)) = todo.pop() { match k { - Object::Array(a) => todo.extend(a), + Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, above))), // A structure element written in place: its kids are followed; one that tags a // removed annotation can't be changed here (see above). Object::Dict(d) if d.contains(b"S") => { @@ -397,11 +482,13 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet if kids.iter().any(|x| refers_to(doc, x, gone)) { return Err(RedactError::Tags); } - todo.extend(kids); + let above = above.through(&d); + todo.extend(kids.into_iter().map(|x| (x, above))); } // A number or name tree node written in place (§7.9.6, §7.9.7): its values and kids. - Object::Dict(d) => todo.extend(tree_node_entries(&d)), + Object::Dict(d) => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, above))), Object::Ref(r) => { + parents.record(r, above); if !seen.insert(r) { continue; } @@ -410,26 +497,26 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet } let object = doc.get(r); if let Object::Array(a) = &*object { - todo.extend(a.iter().cloned()); + todo.extend(a.iter().cloned().map(|x| (x, above))); continue; } let Some(d) = object.as_dict() else { continue }; if !d.contains(b"S") { // A parent tree or ID tree node: its values may be elements. - todo.extend(tree_node_entries(d)); + todo.extend(tree_node_entries(d).into_iter().map(|x| (x, above))); continue; } let kids = kids_of(doc, d); if kids.iter().any(|x| refers_to(doc, x, gone)) { hits.push(r); } - todo.extend(kids); + todo.extend(kids.into_iter().map(|x| (x, Above::element(r)))); } _ => {} } } let mut n = 0; - for r in hits { + for &r in &hits { let Some(d) = doc.get(r).as_dict().cloned() else { continue }; let kids = d.get(b"K").map(|k| doc.resolve(k)); let array = kids.as_ref().is_some_and(|k| matches!(&**k, Object::Array(_))); @@ -449,15 +536,15 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet } else { e.remove(b"K"); } - // An element left with nothing to tag keeps no text describing what it tagged: - // a link's alternate text often repeats the URL, an annotation's its comment. - if keep.is_empty() { - for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] { - e.remove(key); - } + // An element that lost an annotation keeps no text describing it, even with other + // kids left: a link's alternate text often repeats the URL, an annotation's its + // comment. Elements above it may describe it too. + for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] { + e.remove(key); } })?; } + clear_ancestors(doc, &hits, &parents, limit)?; Ok(n) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 94081f9e3..ebc95f451 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2461,6 +2461,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } } +mod descriptions; mod limits; mod property_retirement; mod surviving_references; diff --git a/crates/redact/src/tests/descriptions.rs b/crates/redact/src/tests/descriptions.rs new file mode 100644 index 000000000..df6b8555d --- /dev/null +++ b/crates/redact/src/tests/descriptions.rs @@ -0,0 +1,64 @@ +//! Descriptions in the structure tree (alternate text, actual text, expansions) go with the +//! content they describe, on the element that owns it and on every element above it. + +use super::surviving_references::full_save_holds; +use super::*; + +/// A tagged page: "SECRET" (MCID 0) inside a section whose descriptions repeat it, and "Public" +/// (MCID 1). `paragraph_parent` is the secret paragraph's /P. +fn sectioned(paragraph_parent: &str) -> Document { + let content = b"/P <> BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 10 100 Td (Public) Tj ET EMC"; + pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 7 0 R /MarkInfo << /Marked true >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", content), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Type /StructTreeRoot /K 8 0 R >>".to_vec(), + b"<< /S /Document /P 7 0 R /K [11 0 R 10 0 R] >>".to_vec(), + format!("<< /S /P /P {paragraph_parent} /Pg 3 0 R /K 0 >>").into_bytes(), + b"<< /S /P /P 8 0 R /Pg 3 0 R /ActualText (Public) /K 1 >>".to_vec(), + b"<< /S /Sect /P 8 0 R /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /K 9 0 R >>".to_vec(), + ]) +} + +#[test] +fn an_ancestor_describing_redacted_content_loses_its_descriptions() { + // The paragraph names its section as parent, or (wrongly) the document: the section is found + // either way, by its /K. + for parent in ["11 0 R", "8 0 R"] { + let mut doc = sectioned(parent); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + assert!(!full_save_holds(&doc, b"LEAK_"), "a description of the redacted word survived (paragraph /P {parent})"); + let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap(); + assert!(public.contains(b"ActualText"), "an element beside it keeps its own"); + } +} + +#[test] +fn a_link_element_that_keeps_its_text_loses_the_description_of_its_removed_annotation() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] /StructParents 0 >>" + .to_vec(), + stream("", b"/Link << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"), + FONT.replace("95 0 R", "12 0 R").into_bytes(), + b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://example.org/) >> >>".to_vec(), + b"null".to_vec(), + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [11 0 R] 1 11 0 R] >> /ParentTreeNextKey 2 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K 10 0 R >>".to_vec(), + b"<< /Type /StructElem /S /P /P 9 0 R /ActualText (See LEAK_PARENT) /K 11 0 R >>".to_vec(), + // The link element tags its text and the annotation; its descriptions repeat the address. + b"<< /Type /StructElem /S /Link /P 10 0 R /Pg 3 0 R /Alt (LEAK_ALT) /ActualText (LEAK_ACTUAL) /E (LEAK_E) /T (LEAK_T) /K [0 << /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + widths(), + ]); + let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap(); + assert!(done.contains(&(Hidden::LinksActionsScripts, 1)), "{done:?}"); + assert!(!full_save_holds(&doc, b"LEAK_"), "a description of the removed link survived"); + let link = doc.get(ObjRef::new(11, 0)).as_dict().cloned().unwrap(); + assert_eq!(link.get(b"K"), Some(&Object::Array(vec![Object::Int(0)])), "the element keeps its text"); +} diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 2c8bdb09c..d3aa98af6 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -3,7 +3,7 @@ use super::*; /// Whether `secret` survives a full save of `doc`, in the file's bytes or in a decoded stream. -fn full_save_holds(doc: &Document, secret: &[u8]) -> bool { +pub(super) fn full_save_holds(doc: &Document, secret: &[u8]) -> bool { let bytes = write_full(doc, &SaveOptions::default()).unwrap(); let found = |data: &[u8]| data.windows(secret.len()).any(|w| w == secret); if found(&bytes) { From b72078871a307ad8d28b9e3f861885435fe10834 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:09:57 +0000 Subject: [PATCH 107/142] fix(content): end inline images where the renderer ends them Filtered inline image data has no length, so its end is an EI chosen among candidates, and the content parser chose by a different rule than the renderer's tokenizer (hayro-syntax): the renderer also takes an EI with no whitespace before it, and checks a candidate by reading what follows as operators. With image data such as "7FEI 1 0 0 1 200 0 cm", the renderer ran a cm that redaction read as image data, so text was drawn 200 pt from where removal and its verify pass looked. The parser now takes each inline image's end from the renderer's tokenizer (already in the tree through pdfcraft-cos), so both read the same operators after the image; a test runs both on the same fixtures. Only where that tokenizer reads no image (no EI it accepts, or parameters it can't read) does the parser fall back to its own rules, counting the image in Parsed::ambiguous_images, and applying redactions refuses such a page as unreadable. --- Cargo.lock | 1 + crates/content/Cargo.toml | 3 + crates/content/src/lib.rs | 120 ++++++++++++++++++----- crates/content/src/tests.rs | 58 +++++++++-- crates/engine/src/tests.rs | 24 +++++ crates/redact/src/interp.rs | 6 ++ crates/redact/src/lib.rs | 6 ++ crates/redact/src/tests.rs | 1 + crates/redact/src/tests/inline_images.rs | 34 +++++++ 9 files changed, 224 insertions(+), 29 deletions(-) create mode 100644 crates/redact/src/tests/inline_images.rs diff --git a/Cargo.lock b/Cargo.lock index a95014924..e318b7a77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4042,6 +4042,7 @@ version = "0.11.0" name = "pdfcraft-content" version = "0.11.0" dependencies = [ + "hayro-syntax", "pdfcraft-cos", ] diff --git a/crates/content/Cargo.toml b/crates/content/Cargo.toml index c553df0b8..e6b4c28a4 100644 --- a/crates/content/Cargo.toml +++ b/crates/content/Cargo.toml @@ -10,3 +10,6 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } +# The renderer's content tokenizer, already in the tree through pdfcraft-cos: inline images end +# where the renderer ends them. +hayro-syntax = { workspace = true } diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index eb126ffa2..097ec2689 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -60,6 +60,10 @@ pub struct Parsed { pub ops: Vec, /// Bytes that could not be read as operands or operators. pub skipped: usize, + /// Inline images the renderer's tokenizer reads no image for (it stops, or finds no `EI` it + /// accepts). Their end here is a guess, so the operators after them may not be the ones the + /// page draws. + pub ambiguous_images: usize, /// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before /// and whether one runs out of it into the next. Only boundaries are recorded, so the size /// depends on the number of pieces, not of comments. @@ -142,19 +146,27 @@ fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed { start.get_or_insert(at); operands.push(Object::Null); } - b"BI" => match inline_image(data, lx.pos) { - Ok((dict, img, end)) => { - lx.pos = end; - out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end }); - operands.clear(); + b"BI" => { + let image = inline_image(data, at, lx.pos); + out.ambiguous_images += usize::from(image.ambiguous); + let end = image.end; + lx.pos = end; + match image.image { + Some((dict, img)) => { + out.ops.push(Op { + op: b"BI".to_vec(), + operands: Vec::new(), + inline: Some((dict, img)), + span: start.take().unwrap_or(at)..end, + }); + } + None => { + out.skipped += end.saturating_sub(at); + start = None; + } } - Err(end) => { - out.skipped += end.saturating_sub(at); - lx.pos = end; - start = None; - operands.clear(); - } - }, + operands.clear(); + } _ => { let s = start.take().unwrap_or(at); out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos }); @@ -198,11 +210,29 @@ fn comment_edges(data: &[u8], trivia: std::ops::Range, ends: &[usize], bo } } -/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds -/// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read. -fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize> { +/// An inline image as [`inline_image`] reads it. +struct Inline { + /// Its parameters and data; `None` when the parameters can't be read. + image: Option<(Dict, Vec)>, + /// The position after its `EI` (or the end of the stream). + end: usize, + /// The renderer's tokenizer reads no image here, so `end` is this parser's guess. + ambiguous: bool, +} + +/// Read the inline image whose `BI` keyword starts at `bi` (its parameters start at `pos`). +/// +/// Filtered image data has no length, and `EI` can occur inside it, so where it ends is a +/// heuristic choice among `EI` candidates. The renderer's choice decides which operators the +/// page draws after the image, and redaction must see the same ones, so the end is taken from +/// the renderer's tokenizer (hayro-syntax) rather than worked out here by another rule. Only +/// when that tokenizer reads no image does this parser fall back to its own rules, and the +/// image is marked ambiguous. +fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline { let mut lx = Lexer::new(data, pos); let mut dict = Dict::new(); + let mut readable = true; + let mut stop = pos; loop { lx.skip_ws(); if lx.eat_keyword(b"ID") { @@ -216,22 +246,68 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize let Some((k, v)) = entry else { // A stream that ends inside the parameters is cut short, not unreadable: keep what was read. if at >= data.len() { - return Ok((dict, Vec::new(), data.len())); + return Inline { image: Some((dict, Vec::new())), end: data.len(), ambiguous: false }; } - // Without an `EI` after it, the image runs to the end of the stream. - return Err(end_of_image(data, at, b"").map_or(data.len(), |(_, end)| end)); + readable = false; + stop = at; + break; }; dict.set(k, v); } + if let Some((start, end_data, end)) = renderer_image_end(data, bi) { + // Where the renderer ends the data at an `EI` found by its heuristic, one whitespace byte + // before that `EI` separates it from the data. + let mut samples = data.get(start..end_data).unwrap_or_default(); + if data.get(end_data..).is_some_and(|rest| rest.starts_with(b"EI")) + && let Some((last, rest)) = samples.split_last() + && is_ws(*last) + { + samples = rest; + } + return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: false }; + } + if !readable { + // Without an `EI` after it, the image runs to the end of the stream. + return Inline { image: None, end: end_of_image(data, stop, b"").map_or(data.len(), |(_, end)| end), ambiguous: true }; + } // One whitespace byte separates ID from the data. let begin = (lx.pos + 1).min(data.len()); let end = unfiltered_image_end(data, begin, &dict) .or_else(|| declared_image_end(data, begin, &dict)) .or_else(|| image_data_end(data, begin, filter_eod(&dict))); - Ok(match end { - Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end), - None => (dict, data[begin..].to_vec(), data.len()), - }) + let (image, end) = match end { + Some((end_data, end)) => (data.get(begin..end_data).unwrap_or_default().to_vec(), end), + None => (data.get(begin..).unwrap_or_default().to_vec(), data.len()), + }; + Inline { image: Some((dict, image)), end, ambiguous: true } +} + +/// Where the renderer's tokenizer ends the inline image whose `BI` starts at `bi`: the start and +/// end of its data and the position after its `EI`. `None` when it reads no image there (its +/// parameters don't read, or no `EI` passes its checks). +fn renderer_image_end(data: &[u8], bi: usize) -> Option<(usize, usize, usize)> { + use hayro_syntax::content::TypedIter; + use hayro_syntax::content::ops::{InlineImage, TypedInstruction}; + let tail = data.get(bi..)?; + let mut iter = TypedIter::new(tail); + let (start, len) = match iter.next()? { + TypedInstruction::InlineImage(InlineImage(stream)) => match stream.raw_data() { + // The data is a slice of `tail`: its offset is where it starts. + std::borrow::Cow::Borrowed(samples) => ((samples.as_ptr() as usize).checked_sub(tail.as_ptr() as usize)?, samples.len()), + std::borrow::Cow::Owned(_) => return None, + }, + _ => return None, + }; + let end_data = start.checked_add(len)?; + // `committed` is past the whitespace after `EI`; the image ends at the `EI`. + let mut end = iter.committed(); + while end > end_data && tail.get(end - 1).is_some_and(|b| is_ws(*b)) { + end -= 1; + } + if end < end_data.checked_add(2)? || tail.get(end - 2..end) != Some(b"EI") { + return None; + } + Some((bi.checked_add(start)?, bi.checked_add(end_data)?, bi.checked_add(end)?)) } /// The end-of-data marker (`>` for ASCIIHex, `~>` for ASCII85) of the outer stream filter, if any. diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 094a0665d..c9abef834 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -102,10 +102,11 @@ fn unfiltered_inline_masks_default_to_one_bit_and_indexed_images_have_one_compon let parsed = inline_pixels(parameters.as_bytes(), b" EI "); assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI "); assert_eq!(parsed.ops.len(), 3); - // Indexed samples permit at most 8 bits, even though device spaces permit 16. + // Indexed samples permit at most 8 bits, even though device spaces permit 16, so the + // length is unknown, and the renderer takes the second `EI`. let invalid = parameters.replace("/W 4", "/W 2").replace("/BPC 8", "/BPC 16"); let parsed = inline_pixels(invalid.as_bytes(), b" EI "); - assert!(parsed.ops[1].inline.as_ref().unwrap().1.is_empty()); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI "); } } @@ -127,7 +128,7 @@ fn unfiltered_inline_image_terminator_must_follow_its_samples() { } #[test] -fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() { +fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them() { for parameters in [ b"/W 4 /H 1 /BPC 8 /CS /G /F /A85".as_slice(), b"/W 4 /H 1 /BPC 8 /CS /ResourceColor", @@ -142,12 +143,20 @@ fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() { b"/W 9223372036854775807 /H 9223372036854775807 /BPC 16 /CS /CMYK", b"/W 9223372036854775807 /H 2 /BPC 8 /CS /G", ] { + // What follows the first `EI` (`!!~>`) does not read as content, so the renderer takes + // the later one. let parsed = inline_pixels(parameters, b"! EI !!~>"); - assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"!", "{}", String::from_utf8_lossy(parameters)); + let label = String::from_utf8_lossy(parameters); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"! EI !!~>", "{label}"); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), [b"q".as_slice(), b"BI", b"Q"], "{label}"); + assert_eq!(parsed.ambiguous_images, 0, "{label}"); } - // A short payload still uses the tolerant recovery, including when there is no EI. - for src in [b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc"] { - assert_eq!(parse(src).ops[0].inline.as_ref().unwrap().1, b"abc"); + // A short payload is read whole; without an `EI`, the renderer reads no image and this + // parser's end is a guess. + for (src, ambiguous) in [(b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), 0), (b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc", 1)] { + let parsed = parse(src); + assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abc"); + assert_eq!(parsed.ambiguous_images, ambiguous); } } @@ -459,3 +468,38 @@ fn ascii85_inline_image_does_not_terminate_at_gt_ei_inside_payload() { assert_eq!(img, b"!!!!>EI !!~>"); } } + +/// The operators the renderer's tokenizer reads from `data`. +fn renderer_operators(data: &[u8]) -> Vec> { + let mut iter = hayro_syntax::content::UntypedIter::new(data); + let mut out = Vec::new(); + while let Some(instruction) = iter.next() { + out.push(instruction.operator.as_ref().to_vec()); + } + out +} + +/// Inline images end where the renderer ends them, so both read the same operators after one: +/// an `EI` without whitespace before it in the data, upper-case text ending in "EI" after the +/// image, and an `EI` inside compressed bytes (#3579). +#[test] +fn inline_images_end_where_the_renderer_ends_them() { + let flate: Vec = (0..FLATE_WITH_EI.len()).step_by(2).map(|i| u8::from_str_radix(&FLATE_WITH_EI[i..i + 2], 16).unwrap()).collect(); + let mut stored = b"q BI /W 64 /H 1 /BPC 8 /CS /G /F /Fl ID ".to_vec(); + stored.extend_from_slice(&flate); + stored.extend_from_slice(b"\nEI Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET"); + let fixtures: [&[u8]; 6] = [ + b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", + b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F> EI BT /F1 10 Tf 10 100 Td (ZWEI) Tj (EIN FREI BEI) Tj ET", + b"q BI /W 2 /H 1 /CS /G /BPC 8 /F /AHx ID 7F7F> EI Q BT (DREI) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 00> EI", + b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID ! EI !!~>\nEI Q", + b"q BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q", + &stored, + ]; + for data in fixtures { + let parsed = parse(data); + let ours: Vec> = parsed.ops.iter().map(|op| op.op.clone()).collect(); + assert_eq!(ours, renderer_operators(data), "{}", String::from_utf8_lossy(data)); + assert_eq!((parsed.skipped, parsed.ambiguous_images), (0, 0), "{}", String::from_utf8_lossy(data)); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8937cf678..3012a1f3f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2320,6 +2320,30 @@ fn the_renderer_check_refuses_text_still_shown_under_a_mark() { assert!(refused(&cos), "the labelled input stream was not checked"); } +/// Inline image data `7FEI 1 0 0 1 200 0 cm` holds an `EI` the renderer ends the image at, so it +/// runs the `cm` and shows SECRET 200 pt to the right. Redaction reads the image the same way, so +/// a mark drawn over the word as shown takes it out of the file. +#[test] +fn text_after_an_inline_image_is_redacted_where_the_page_shows_it() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page( + "/Font << /F1 4 0 R >>", + "BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", + ); + let id = s.open("inline.pdf", None, Arc::new(page), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + assert!(!page_texts(&s, id).concat().contains("SECRET")); + let saved = s.save_bytes(id).unwrap(); + let held = |d: &[u8]| d.windows(6).any(|w| w == b"SECRET"); + assert!(!held(&saved), "the saved file holds the redacted word"); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!( + !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), + "a saved stream holds the redacted word" + ); +} + /// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer /// draws it, so a mark drawn over it as displayed takes it out of the file. #[test] diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index fca3a1da8..b4186fd4e 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -74,6 +74,10 @@ pub(crate) struct Output { /// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was /// in force: the operation must fail closed rather than leave that text under the mark. pub vertical: bool, + /// The content holds an inline image the renderer reads no image for + /// ([`pdfcraft_content::Parsed::ambiguous_images`]): what this scope placed after it may not + /// be what the page shows, so a redaction must not trust it. + pub ambiguous: bool, pub tag_changes: Vec<(ObjRef, ObjRef, HashSet, HashSet)>, pub error: Option, } @@ -789,6 +793,7 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); + out.ambiguous = parsed.ambiguous_images > 0; // A rewritten piece loses its comments, so keep where they began and ended: a comment that // ran on into the next piece still hides that piece's first line. let comment_edges = joined.comment_edges(&parsed); @@ -1293,6 +1298,7 @@ fn xobject( scope.fonts = saved_fonts; out.residue += inner.residue; out.vertical |= inner.vertical; + out.ambiguous |= inner.ambiguous; out.doomed.extend_from_slice(&inner.doomed); out.retired_properties.extend(inner.retired_properties); out.tag_changes.extend(inner.tag_changes); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 97d5bcd9f..e67b373a1 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -411,6 +411,12 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", "", vec![]); + mark(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + assert!(!content(&doc, 0).contains("SECRET")); +} + +/// Without an `EI` the renderer accepts, where the operators resume is a guess: refused. +#[test] +fn a_page_whose_inline_image_the_renderer_cannot_end_is_refused() { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F", "", vec![]); + mark(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1))); +} From 01aa6bcc17b09ad294777d7df49f6b52d6c275fe Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:31:38 +0000 Subject: [PATCH 108/142] docs(redact): describe the overlay, text state, description and inline image rules --- crates/content/README.md | 11 +++++++---- crates/redact/README.md | 22 ++++++++++++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/crates/content/README.md b/crates/content/README.md index 6ab984dd4..fedeadc56 100644 --- a/crates/content/README.md +++ b/crates/content/README.md @@ -13,7 +13,10 @@ The parser is tolerant (unreadable bytes are skipped and counted, never fatal) a panics. `redact` and (later) `edit` build their interpreters on it; the graphics-state tracker and the `Patch` API of architecture §6 grow here. -Unfiltered inline images with built-in or Indexed colour spaces, and image masks, use their -sample layout (including per-row byte padding) to locate `EI`. This preserves pixel bytes -that look like the terminator. Filtered images and resource-defined colour spaces retain -the tolerant delimiter heuristic; damaged or truncated layouts fall back to it too. +An inline image ends where the renderer's tokenizer (`hayro-syntax`) ends it, so the parser +and the renderer read the same operators after the image, and redaction removes what the page +actually draws. Only where that tokenizer reads no image (its parameters don't read, or no `EI` +passes its checks) does the parser locate `EI` by its own rules: unfiltered images with built-in +or Indexed colour spaces, and image masks, by their sample layout (including per-row byte +padding), other images by the tolerant delimiter heuristic. Such an image is counted in +`Parsed::ambiguous_images`, because the operators after it may not be the ones the page draws. diff --git a/crates/redact/README.md b/crates/redact/README.md index 2caf067b5..cca3d51fd 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -20,7 +20,8 @@ clear_marks(&mut doc, None)?; // remove marks without app - **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx). Codecs PdfCraft can't re-encode (DCT, JPX, JBIG2, CCITT) are removed whole (fail-closed); inline images under an - area are removed. Images with separate `/SMask` or `/Mask` data are removed whole; + area are removed. Images with separate `/SMask` or `/Mask` data, and images whose data decodes + to more than twice their declared raster (plus a byte per row and 4 KiB), are removed whole; cleared copies drop `/Alternates` so alternate pixels cannot survive; - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; @@ -31,8 +32,13 @@ clear_marks(&mut doc, None)?; // remove marks without app page as a `/PCMark /Redaction` stream. A verification pass re-reads each redacted page and fails the operation if any glyph or inline -image is still under an area. Content that can't be decoded makes the operation fail rather than -leave it unredacted. A Redact annotation with neither a usable `/QuadPoints` array nor a usable +image is still under an area. It checks every content stream the page had, whatever its +dictionary calls it; only the overlay streams this `apply` added (`Report::overlays`) are skipped. +Text is placed with the font and size set by `Tf` or by an ExtGState's `/Font` through `gs`, and a +form XObject starts from the text state of the content that draws it. Content that can't be +decoded makes the operation fail rather than leave it unredacted, and so does an inline image the +renderer's tokenizer reads no image for (`RedactError::Unreadable`): where the page's operators +resume after it would be a guess. A Redact annotation with neither a usable `/QuadPoints` array nor a usable `/Rect` makes it fail with `RedactError::UnreadableMark` before anything changes, instead of skipping that mark. The count covers the marks on the pages being applied. @@ -50,15 +56,19 @@ as Sanitize drops them, so a full rewrite of a tagged PDF can't keep a removed c A tree where that can't be done (an element written in place, or one too large to walk) makes `apply` fail with `RedactError::Tags` rather than leave the annotation reachable. -Replacement descriptions are removed only from marked-content spans changed by cleanup. -Unchanged spans retain their descriptions; unused property entries are retired during redaction +Replacement descriptions (`/Alt`, `/ActualText`, `/E`) are removed from marked-content spans +changed by cleanup, from every structure element above them (found through both `/P` and `/K`), +and from elements that lose an annotation reference, which also lose `/T`. An element written in +place above one whose descriptions go makes `apply` fail with `RedactError::Tags`. Unchanged +spans retain their descriptions; unused property entries are retired during redaction and hidden-content cleanup, including aliases in shared/inherited resources. Descriptions still used by surviving pages or forms are kept. A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference; a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's `/Resources` is then retired unless another page still inherits it, so it cannot keep a removed -XObject reachable. Pages that aren't redacted are left untouched. +XObject reachable. Surviving form XObjects lose the resource entries for removed XObjects their own +content doesn't draw, for the same reason. Pages that aren't redacted are left untouched. ## Remove hidden information / Sanitize From 8555d6f159a7b2d7d420185642b8901719b457a6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:43:38 +0000 Subject: [PATCH 109/142] fix(parity): cite the renamed renderer-check test and drop fixed redaction gaps --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 362c2aa77..45dda3982 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5709,8 +5709,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::redaction_is_refused_when_the_page_still_shows_text_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"] -notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text whose font is set through an ExtGState or taken by a form from its caller, some predefined CMaps and rotated Type 3 fonts are not removed yet, so those documents cannot be redacted. Removed images and forms are unlinked from page-tree resources (own, inherited or shared), not yet from a form's own resources, annotation appearances or patterns" +evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"] +notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" [[feature]] id = "protect.redact-glyph-removal" From 39844f06b58bc96bae72d3fb56faaa3b6201c945 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 15:39:48 -0400 Subject: [PATCH 110/142] Release 0.12.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 72 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 68 +++++++++++++++++++++++++-------------------------- 2 files changed, 70 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e318b7a77..651843001 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3936,7 +3936,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "craft-quit", @@ -3962,7 +3962,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3974,7 +3974,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3985,7 +3985,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3996,7 +3996,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "flate2", @@ -4018,7 +4018,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4036,11 +4036,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.11.0" +version = "0.12.0" [[package]] name = "pdfcraft-content" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4048,7 +4048,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "hayro-syntax", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.11.0" +version = "0.12.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.11.0" +version = "0.12.0" dependencies = [ "aes", "md-5", @@ -4091,7 +4091,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4106,7 +4106,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "craft-docpath", @@ -4147,14 +4147,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.11.0" +version = "0.12.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.11.0" +version = "0.12.0" dependencies = [ "flate2", "proptest", @@ -4164,7 +4164,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.11.0" +version = "0.12.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4174,7 +4174,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4187,11 +4187,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.11.0" +version = "0.12.0" [[package]] name = "pdfcraft-js" -version = "0.11.0" +version = "0.12.0" dependencies = [ "boa_engine", "chrono", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4212,14 +4212,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.11.0" +version = "0.12.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4229,7 +4229,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "pdfcraft-annot", @@ -4242,11 +4242,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.11.0" +version = "0.12.0" [[package]] name = "pdfcraft-organize" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4257,7 +4257,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-channel", "futures-lite", @@ -4267,7 +4267,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.11.0" +version = "0.12.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4278,7 +4278,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.11.0" +version = "0.12.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4306,7 +4306,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.11.0" +version = "0.12.0" dependencies = [ "bytemuck", "flate2", @@ -4325,7 +4325,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.11.0" +version = "0.12.0" dependencies = [ "aes", "aws-lc-rs", @@ -4360,11 +4360,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.11.0" +version = "0.12.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.11.0" +version = "0.12.0" dependencies = [ "arboard", "base64 0.22.1", @@ -4409,7 +4409,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.11.0" +version = "0.12.0" dependencies = [ "eframe", "egui", @@ -4421,7 +4421,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "image", @@ -4435,7 +4435,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index 201240314..5841b50c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.11.0" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1. # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.11.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.11.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.11.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.11.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.11.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.11.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.11.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.11.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.11.0" } -pdfcraft-model = { path = "crates/model", version = "0.11.0" } -pdfcraft-content = { path = "crates/content", version = "0.11.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.11.0" } -pdfcraft-print = { path = "crates/print", version = "0.11.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.11.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.11.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.11.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.11.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.11.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.11.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.11.0" } -pdfcraft-js = { path = "crates/js", version = "0.11.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.11.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.11.0" } -pdfcraft-export = { path = "crates/export", version = "0.11.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.11.0" } -pdfcraft-create = { path = "crates/create", version = "0.11.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.11.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.11.0" } -pdfcraft-render = { path = "crates/render", version = "0.11.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.11.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.11.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.11.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.11.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.12.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.12.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.12.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.12.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.12.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.12.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.12.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.12.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.12.0" } +pdfcraft-model = { path = "crates/model", version = "0.12.0" } +pdfcraft-content = { path = "crates/content", version = "0.12.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.12.0" } +pdfcraft-print = { path = "crates/print", version = "0.12.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.12.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.12.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.12.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.12.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.12.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.12.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.12.0" } +pdfcraft-js = { path = "crates/js", version = "0.12.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.12.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.12.0" } +pdfcraft-export = { path = "crates/export", version = "0.12.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.12.0" } +pdfcraft-create = { path = "crates/create", version = "0.12.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.12.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.12.0" } +pdfcraft-render = { path = "crates/render", version = "0.12.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.12.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.12.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.12.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.12.0", default-features = false } # pdf hayro = "0.7.1" From 3dbdc13b1d8b2eaeca3b26619ef16790945f5df3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:46:11 +0000 Subject: [PATCH 111/142] Image resolution walk charges each form entry to the page's form allowance --- crates/optimize/src/images.rs | 4 ++++ crates/optimize/tests/optimize.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 0eb882ec2..896542285 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -127,6 +127,10 @@ impl Walk<'_> { } self.rewalks += 1; } + if !self.budget.enter_form() { + self.complete = false; + return; + } let Ok(data) = self.budget.decode(s) else { self.complete = false; return; diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index fd1ef3cd9..6cf53d4fc 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1023,6 +1023,19 @@ fn images_are_not_resampled_when_some_uses_could_not_be_measured() { assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); } +/// Each form entry counts against the page's form allowance, even for a form that decodes to +/// nothing and so costs no decoded bytes. +#[test] +fn images_are_not_resampled_when_a_page_enters_more_forms_than_allowed() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, false, None); + let empty = form(&mut doc, None, ""); + let draws = "/E Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1); + page(&mut doc, &[("E", empty), ("Im", img)], &format!("q 72 0 0 72 36 36 cm /Im Do Q {draws}")); + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); +} + #[test] fn image_resolution_keeps_comments_across_content_pieces() { for ending in ["", "\n", "\r", "\r\n"] { From e0e7d4462eb127f850b01b0221227634295afb77 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:46:50 +0000 Subject: [PATCH 112/142] Cover audit charges each form entry to the page's form allowance --- crates/audit/src/cover.rs | 10 +++++++--- crates/audit/src/lib.rs | 2 ++ crates/audit/src/tests.rs | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 516bb193d..93394ccfb 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -109,7 +109,8 @@ struct Walker<'a> { pieces: &'a Pieces, out: Vec, ops_seen: usize, - /// Decoded bytes left for the page, its forms included: spending it fails the audit. + /// Decoded bytes and form entries left for the page, its forms included: spending either + /// fails the audit. budget: DecodeBudget, } @@ -273,6 +274,9 @@ impl<'a> Walker<'a> { }) .unwrap_or(Matrix::IDENTITY); let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + if !self.budget.enter_form() { + return Err(AuditError::TooManyForms); + } // A form stream that cannot be decoded fails the audit: it might hide a cover. let data = self.budget.decode_strict(stream)?; let ops = parse(&data).ops; @@ -306,8 +310,8 @@ fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { /// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint /// positions. A page's content streams are walked once, joined, with one graphics state, so /// state set in an earlier stream is still in force in the later ones. Any content stream that -/// cannot be decoded, or content and forms that decode past one page's [`DecodeBudget`] in all, -/// is an audit failure, never an empty result. +/// cannot be decoded, or content and forms that decode or enter past one page's [`DecodeBudget`] +/// in all, is an audit failure, never an empty result. pub fn filled_rects(doc: &Document, page: &Dict) -> Result, AuditError> { let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let contents: Vec = match page.get(b"Contents") { diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 365889d61..8277cb0aa 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -77,6 +77,8 @@ pub enum AuditError { TooManyOps, #[error("form XObjects nest deeper than the audit follows")] TooDeep, + #[error("content walk entered more form XObjects than the audit follows")] + TooManyForms, #[error("page has a malformed /Annots entry")] BadAnnots, #[error(transparent)] diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 8155c6dec..3d0c80e0b 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -611,3 +611,17 @@ fn repeated_stream_references_share_one_decode_budget() { let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); assert!(crate::cover::filled_rects(&doc, &page).is_err(), "repeated form draws"); } + +#[test] +fn form_entries_past_the_page_allowance_fail_the_audit() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + // An empty form decodes to no bytes, so only the count of entries bounds drawing it again. + let mut doc = Document::new_empty(); + let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::from_raw(form_dict, Vec::new())); + let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1).into_bytes())); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); + assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms))); +} From e2a67716419d7a6c186ce45e48542dbf444288f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:48:33 +0000 Subject: [PATCH 113/142] Redaction charges a page's content streams and its forms to one decode budget --- crates/redact/src/interp.rs | 11 +++++++---- crates/redact/src/lib.rs | 22 ++++++++++++++++----- crates/redact/src/sanitize.rs | 7 ++++--- crates/redact/src/tests.rs | 36 +++++++++++++++++++++++++++++++++-- 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 0a3b730f6..9851c2e8c 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -106,13 +106,16 @@ pub(crate) struct Scope<'a> { fonts: HashMap, Rc>, used_names: Vec>, depth: usize, - /// Form entries and decoded form bytes left for this page: shared forms drawn from other - /// shared forms would otherwise multiply the work with every level. + /// Form entries and decoded bytes left for this page, its content streams already charged: + /// shared forms drawn from other shared forms would otherwise multiply the work with every + /// level. budget: DecodeBudget, } impl<'a> Scope<'a> { - pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report) -> Self { + /// A scope whose form walk spends `budget`, what is left of the page's allowance after its + /// content streams were decoded. + pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget) -> Self { Scope { rects, mode, @@ -127,7 +130,7 @@ impl<'a> Scope<'a> { fonts: HashMap::new(), used_names: Vec::new(), depth: 0, - budget: DecodeBudget::page(), + budget, } } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index f84797720..fce9f432a 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -230,6 +230,17 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec, Vec) { /// A page's content streams: (the references or inline objects as listed, their decoded data), /// decoded within one page's [`pdfcraft_cos::DecodeBudget`]. pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec, Vec>), RedactError> { + page_streams_within(doc, page, index, &mut pdfcraft_cos::DecodeBudget::page()) +} + +/// [`page_streams`], decoded against `budget`, which the walk of the page's forms then goes on +/// spending. +pub(crate) fn page_streams_within( + doc: &Document, + page: &Dict, + index: usize, + budget: &mut pdfcraft_cos::DecodeBudget, +) -> Result<(Vec, Vec>), RedactError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -238,7 +249,6 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result< }, }; let mut data = Vec::new(); - let mut budget = pdfcraft_cos::DecodeBudget::page(); for o in &list { match &*doc.resolve(o) { // Strict: a tolerant decode keeps what precedes corruption and drops the rest, which @@ -396,10 +406,11 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); // 1. Content. - let (list, data) = page_streams(doc, &page.dict, pi)?; + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let out = { - let mut scope = Scope::new(&rects, Mode::Apply, &mut report); + let mut scope = Scope::new(&rects, Mode::Apply, &mut report, budget); process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) }; // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot @@ -601,13 +612,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); - let (list, data) = page_streams(doc, &page.dict, pi)?; + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; // The overlay stream draws text of its own: the overlay label is allowed. let original: Vec> = list.iter().zip(data).filter(|(o, _)| !o.as_ref().is_some_and(|r| report.overlays.contains(&r))).map(|(_, d)| d).collect(); let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut scratch = Report::default(); - let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch); + let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch, budget); residue += process(doc, &mut scope, &original, &resources, pdfcraft_content::Matrix::IDENTITY).residue; } if residue > 0 { diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 5b2918f01..0b6c04fdf 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -8,7 +8,7 @@ use pdfcraft_content::Matrix; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::interp::{Mode, Scope, process}; -use crate::{RedactError, Report, annots_of, listed_annotations, page_streams}; +use crate::{RedactError, Report, annots_of, listed_annotations, page_streams, page_streams_within}; /// The categories of Acrobat's Remove Hidden Information panel that PdfCraft handles. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -356,7 +356,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let n = pdfcraft_model::pages(doc).len(); for pi in 0..n { let page = pdfcraft_model::pages(doc).swap_remove(pi); - let Ok((list, data)) = page_streams(doc, &page.dict, pi) else { + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let Ok((list, data)) = page_streams_within(doc, &page.dict, pi, &mut budget) else { if write { return Err(RedactError::Unreadable(pi + 1)); } @@ -368,7 +369,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut report = Report::default(); let mode = if write { Mode::Apply } else { Mode::Verify }; - let mut scope = Scope::new(&[], mode, &mut report); + let mut scope = Scope::new(&[], mode, &mut report, budget); if text { let c = page.crop(doc); scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 550903e49..2478e0b31 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -48,10 +48,11 @@ fn content(doc: &Document, page: usize) -> String { /// Glyphs (and inline images) of page `page` under `rects` (the verifier's count). fn under(doc: &mut Document, page: usize, rects: &[[f64; 4]]) -> usize { let p = pdfcraft_model::pages(doc).swap_remove(page); - let (_, data) = page_streams(doc, &p.dict, page).unwrap(); + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (_, data) = page_streams_within(doc, &p.dict, page, &mut budget).unwrap(); let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut rep = Report::default(); - let mut scope = Scope::new(rects, Mode::Verify, &mut rep); + let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget); process(doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY).residue } @@ -2713,3 +2714,34 @@ fn repeated_name_tree_kids_count_towards_the_node_budget() { let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); assert_eq!(counts[&Hidden::Attachments], 1, "the walk went past its node budget to the last kid"); } + +/// Content streams and the forms they draw spend one page allowance: 130 MiB of content and a +/// 130 MiB form are each under it, together past it. +#[test] +fn content_and_forms_share_one_budget_per_page() { + use pdfcraft_cos::{Dict, Stream}; + let mut doc = one_page(b"", "", vec![]); + let mut content = b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET /X Do\n%".to_vec(); + content.resize(130 << 20, b'x'); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), &content))); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + let mut body = b"%".to_vec(); + body.resize(130 << 20, b'x'); + let form = doc.add(Object::Stream(Stream::flate(form, &body))); + doc.update_dict(ObjRef::new(3, 0), |d| { + let mut xobjects = Dict::new(); + xobjects.set(b"X".to_vec(), Object::Ref(form)); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(ObjRef::new(5, 0))); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + d.set(b"Contents".to_vec(), Object::Ref(content)); + }) + .unwrap(); + mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], ""); + assert!(apply(&mut doc, None).is_err(), "the form past the page's allowance was read"); +} From f980b34ef3c5bc866809dba24f76a4a7f2b153a8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:49:40 +0000 Subject: [PATCH 114/142] Text and image reading charge a page's content and its forms to one decode budget --- crates/edit/src/images.rs | 5 +++-- crates/edit/src/tests.rs | 18 +++++++++++++++++ crates/edit/src/text.rs | 41 +++++++++++++++++++++++---------------- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 1f7773a52..6f14271be 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -377,8 +377,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() }; - let pieces = joined(&content_streams(doc, &p.dict)?); + let mut decode = pdfcraft_cos::DecodeBudget::page(); + let pieces = joined(&crate::text::content_streams_within(doc, &p.dict, &mut decode)?); + let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::new(decode) }; walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 61e5e958e..1395fb1c3 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6430,3 +6430,21 @@ fn reading_images_stops_at_the_page_record_limit() { assert!(images.len() <= crate::images::MAX_PAGE_IMAGES, "{} images", images.len()); } } + +#[test] +fn reading_spends_one_decode_budget_on_content_and_forms() { + // 130 MiB of content and a 130 MiB form (a kilobyte each on disk): each fits a page's budget, + // together they don't, so reading leaves the form out and says so. + let doc = page_with(|doc| { + let mut body = b"%".to_vec(); + body.resize(130 << 20, b'x'); + let form: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::flate(form, &body)); + let mut content = b"/X Do\n%".to_vec(); + content.resize(130 << 20, b'x'); + let content = doc.add(Stream::flate(Dict::new(), &content)); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + (Object::Ref(content), [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect()) + }); + assert!(crate::text::reading_blocks_checked(&doc, 0).unwrap().truncated); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 34768527b..1f35bcd47 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -621,6 +621,12 @@ struct Ts { /// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading /// or rewriting a page from part of its content would lose the rest. pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result)>, EditError> { + content_streams_within(doc, page, &mut DecodeBudget::page()) +} + +/// [`content_streams`], decoded against `budget`, which a walk of the page's forms then goes on +/// spending. +pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Result)>, EditError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -628,7 +634,6 @@ pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result vec![c.clone()], }, }; - let mut budget = DecodeBudget::page(); let mut out = Vec::with_capacity(list.len()); for o in list { let data = match &*doc.resolve(&o) { @@ -1500,24 +1505,25 @@ pub const MAX_FORM_DEPTH: usize = 50; pub const MAX_FORM_VISITS: usize = 50_000; /// One page's walk through the form XObjects it draws: how many it entered (the page's -/// [`MAX_FORM_VISITS`]), the bytes their content may decode to in all, and whether a limit left -/// a form unread. +/// [`MAX_FORM_VISITS`]), the bytes the page's content and forms may still decode to, and whether +/// a limit left a form unread. pub(crate) struct FormBudget { visits: usize, decode: DecodeBudget, - /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past - /// [`MAX_FORM_VISITS`]: what it draws is missing from the walk. + /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`], comes past + /// [`MAX_FORM_VISITS`] or past the decode budget: what it draws is missing from the walk. pub(crate) truncated: bool, } +impl FormBudget { + /// The walk of a page's forms, spending what `decode` has left after the page's content. + pub(crate) fn new(decode: DecodeBudget) -> Self { + Self { visits: 0, decode, truncated: false } + } +} + /// A form XObject a page draws: its object, content, resources and its matrix composed with the /// CTM at the `Do`. -impl Default for FormBudget { - fn default() -> Self { - Self { visits: 0, decode: DecodeBudget::page(), truncated: false } - } -} - pub(crate) struct FormCall { pub(crate) obj: Option, pub(crate) data: Vec, @@ -1632,8 +1638,9 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E } let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict)?; - let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() }; + let mut decode = DecodeBudget::page(); + let streams = content_streams_within(doc, &p.dict, &mut decode)?; + let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::new(decode) }; let mut carry = Carry::new(); // Read the page's streams as the one stream they are, in one pass, as rendering and editing // do: a comment, an operator's operands or a marked-content sequence (its /ActualText too) @@ -2506,10 +2513,10 @@ pub fn reading_blocks(doc: &Document, page: usize) -> Result, Edi pub struct ReadingBlocks { /// The paragraphs, as [`reading_blocks`] returns them. pub blocks: Vec, - /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], or more of them than - /// [`MAX_FORM_VISITS`]. Reading leaves those out to stay bounded on hostile files, so their - /// text is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses - /// their images too. + /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], more of them than + /// [`MAX_FORM_VISITS`], or more content and forms than one page's decode budget. Reading + /// leaves those out to stay bounded on hostile files, so their text is missing from + /// `blocks`; [`crate::reading_images`] walks the same forms and misses their images too. pub truncated: bool, } From 089882f372ce0c6517be24ada8c80fe236ff14d3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:50:42 +0000 Subject: [PATCH 115/142] Marking a figure decorative refuses content past the page's decode budget --- crates/a11y/src/alt.rs | 13 ++++++++++++- crates/a11y/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 4d2dab646..f22a25f73 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -23,6 +23,8 @@ pub enum AltError { Untagged, #[error("object {0} is not a figure")] NotAFigure(u32), + #[error("the page has more content than can be edited")] + TooLarge, #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } @@ -375,7 +377,16 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: let mut budget = DecodeBudget::page(); for entry in &entries { match &*doc.resolve(entry) { - Object::Stream(s) => streams.push(Some((s.clone(), budget.decode(s).unwrap_or_default()))), + Object::Stream(s) => { + let data = match budget.decode(s) { + Ok(data) => data, + // Content past the budget may hold more of the figure: editing the rest + // would leave that part marked as a figure the tree no longer has. + Err(_) if budget.exhausted() => return Err(AltError::TooLarge), + Err(_) => Vec::new(), + }; + streams.push(Some((s.clone(), data))); + } _ => streams.push(None), } } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 1c79caf3e..7db743536 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1262,3 +1262,19 @@ fn repeated_content_references_share_one_decode_budget() { let data = crate::alt::page_content(&doc, &page); assert!(data.len() <= MAX_PAGE_DECODED + 300, "{} bytes", data.len()); } + +/// A figure whose marked content goes on in a stream past the page's decode budget is refused, +/// not removed with that content still pointing at it. +#[test] +fn marking_decorative_refuses_content_past_the_decode_budget() { + use pdfcraft_cos::{Dict, Object, ObjRef, Stream}; + let mut doc = figures_doc(); + let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let last = doc.add(Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >> BDC 0 0 5 5 re f EMC".to_vec())); + let mut contents = vec![Object::Ref(ObjRef::new(4, 0))]; + contents.extend(std::iter::repeat_n(Object::Ref(filler), 256)); + contents.push(Object::Ref(last)); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap(); + assert!(mark_decorative(&mut doc, ObjRef::new(7, 0)).is_err()); + assert_eq!(figures(&doc).len(), 2, "the figure stays"); +} From 9d5bcc074590f69585f232e485808cc7ba4053b6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:51:47 +0000 Subject: [PATCH 116/142] Accessibility check asks for a manual check where page content ran past the decode budget --- crates/a11y/src/content.rs | 21 +++++++++++++++++++-- crates/a11y/src/lib.rs | 12 ++++++++++++ crates/a11y/src/tests.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index 66f83867e..fd5f0d305 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -20,6 +20,8 @@ pub(crate) struct Scan { pub untagged: BTreeMap, /// Page → fonts used. pub fonts: BTreeMap>, + /// Pages read only in part: content or forms past the page's decode budget are missing. + pub incomplete: BTreeSet, } const MAX_FORM_DEPTH: usize = 12; @@ -32,6 +34,9 @@ impl Scan { let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut budget = DecodeBudget::page(); let data = crate::alt::page_content_within(doc, &page.dict, &mut budget); + if budget.remaining() == 0 { + s.incomplete.insert(p); + } let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new(), budget }; w.content(&data, &res, false, 0); } @@ -100,7 +105,20 @@ impl Walk<'_> { self.scan.has_images = true; untagged += usize::from(!inside); } - Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) && self.budget.enter_form() => { + Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) => { + if !self.budget.enter_form() { + self.scan.incomplete.insert(self.page); + continue; + } + let data = match self.budget.decode(x) { + Ok(data) => data, + Err(_) => { + if self.budget.exhausted() { + self.scan.incomplete.insert(self.page); + } + continue; + } + }; self.forms.insert(r); let inner = x .dict @@ -108,7 +126,6 @@ impl Walk<'_> { .map(|o| self.doc.resolve(o)) .and_then(|o| o.as_dict().cloned()) .unwrap_or_else(|| res.clone()); - let data = self.budget.decode(x).unwrap_or_default(); self.content(&data, &inner, inside, depth + 1); self.forms.remove(&r); } diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 21d8ea582..280f4e845 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -681,6 +681,18 @@ pub fn check(doc: &Document, options: &Options) -> Report { } } } + // A page read only in part may hold what the scan didn't find: text that makes the + // document more than images, untagged content, or a font without Unicode mappings. + let unsure = !scan.incomplete.is_empty() + && match rule { + Rule::ImageOnly => !scan.has_text, + Rule::TaggedContent | Rule::CharacterEncoding => findings.is_empty(), + _ => false, + }; + if unsure { + results.push(RuleResult { rule, status: Status::Manual, findings: Vec::new() }); + continue; + } if total > MAX_FINDINGS { let more = total - MAX_FINDINGS; findings.truncate(MAX_FINDINGS); diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 7db743536..ed3a5146e 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1278,3 +1278,31 @@ fn marking_decorative_refuses_content_past_the_decode_budget() { assert!(mark_decorative(&mut doc, ObjRef::new(7, 0)).is_err()); assert_eq!(figures(&doc).len(), 2, "the figure stays"); } + +/// Text past the page's decode budget is unread, so a page that shows images and no text so far +/// can't be called image-only, nor its content all tagged. +#[test] +fn content_past_the_decode_budget_needs_a_manual_check() { + use pdfcraft_cos::{Dict, Object, ObjRef, Stream}; + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>".into(), + stream("/Artifact BMC q 200 0 0 200 0 0 cm /Im Do Q EMC"), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream" + .into(), + FONT.into(), + ], + "", + ); + let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let text = doc.add(Stream::from_raw(Dict::new(), b"BT /F1 12 Tf 10 10 Td (Hello) Tj ET".to_vec())); + let mut contents = vec![Object::Ref(ObjRef::new(4, 0))]; + contents.extend(std::iter::repeat_n(Object::Ref(filler), 256)); + contents.push(Object::Ref(text)); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap(); + let report = check(&doc, &Options::default()); + assert_eq!(status(&report, Rule::ImageOnly), Status::Manual); + assert_eq!(status(&report, Rule::TaggedContent), Status::Manual); +} From f02b998122731132cbf7c119a7f41630160908d6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:54:10 +0000 Subject: [PATCH 117/142] Format the accessibility tests --- crates/a11y/src/tests.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index ed3a5146e..9332d29df 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1267,7 +1267,7 @@ fn repeated_content_references_share_one_decode_budget() { /// not removed with that content still pointing at it. #[test] fn marking_decorative_refuses_content_past_the_decode_budget() { - use pdfcraft_cos::{Dict, Object, ObjRef, Stream}; + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; let mut doc = figures_doc(); let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); let last = doc.add(Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >> BDC 0 0 5 5 re f EMC".to_vec())); @@ -1283,12 +1283,13 @@ fn marking_decorative_refuses_content_past_the_decode_budget() { /// can't be called image-only, nor its content all tagged. #[test] fn content_past_the_decode_budget_needs_a_manual_check() { - use pdfcraft_cos::{Dict, Object, ObjRef, Stream}; + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; let mut doc = pdf( &[ "<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>" + .into(), stream("/Artifact BMC q 200 0 0 200 0 0 cm /Im Do Q EMC"), "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream" .into(), From 02f0f7396f6b823bc7736fcc0a909b382aed2651 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:04:04 +0000 Subject: [PATCH 118/142] fix(annot): count web-address text after its UTF-8 conversion An address of invalid UTF-8 grows up to three times when converted, so the listing's 16 MiB cap counted fewer bytes than it kept. The cap now counts the converted text; the raw length is still checked first, so an address already over the cap is never converted. --- crates/annot/src/links.rs | 12 ++++++++---- crates/annot/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index b6a2c7575..657488137 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -78,7 +78,8 @@ pub struct LinkItem { pub style: LinkStyle, } -/// Web-address text one listing keeps; past it a link lists as [`LinkAction::Other`]. +/// Bytes of web-address text one listing keeps, counted after conversion to UTF-8; past it a link +/// lists as [`LinkAction::Other`]. pub(crate) const MAX_URI_BYTES: usize = 16 << 20; fn nums(doc: &Document, o: Option<&Object>) -> Vec { @@ -103,10 +104,13 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) Some(b"URI") => { let uri = a.get(b"URI").map(|u| doc.resolve(u)); let bytes = uri.as_ref().and_then(|u| u.as_string()).map_or(&[][..], |s| &s.bytes[..]); - match uri_budget.checked_sub(bytes.len()) { - Some(left) => { + // The raw length first, so an address already over the budget is never converted; then the + // converted text, which is up to three times longer where the bytes are not UTF-8. + let text = (bytes.len() <= *uri_budget).then(|| String::from_utf8_lossy(bytes)); + match text.and_then(|t| Some((uri_budget.checked_sub(t.len())?, t))) { + Some((left, text)) => { *uri_budget = left; - LinkAction::Uri(String::from_utf8_lossy(bytes).into_owned()) + LinkAction::Uri(text.into_owned()) } None => LinkAction::Other("web address too long".into()), } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index c52732ea8..6eb94b830 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1541,6 +1541,25 @@ fn link_listing_skips_repeated_refs_and_caps_web_address_text() { assert!(matches!(&all[0].action, links::LinkAction::Uri(u) if u.len() == 64 << 10)); } +#[test] +fn web_address_cap_counts_the_text_kept_after_conversion() { + // 200 links sharing a 64 KiB address of invalid UTF-8: 12.5 MiB of bytes, each byte kept as a + // three-byte replacement character. + let annots = "<< /Subtype /Link /Rect [0 0 1 1] /A 4 0 R >>".repeat(200); + let doc = doc_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + "<< /S /URI /URI 5 0 R >>".into(), + format!("<{}>", "FF".repeat(64 << 10)), + ]); + let all = links::list(&doc); + assert_eq!(all.len(), 200); + let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum(); + assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept"); + assert!(kept > 0); +} + #[test] fn cloud_outline_has_a_bump_budget() { // 50 long edges would each ask for 10,000 bumps of radius 1. From 071130d49868409f68066c55b74c0d7366e90699 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:05:05 +0000 Subject: [PATCH 119/142] fix(render): list links and attachments after the comment budget fills Reaching the 64 MiB comment budget ended the whole annotation scan, so links and file attachments listed after it were missing, and a later JavaScript link left the document unmarked. Past the budget, comments are no longer built, and the scan goes on for links and attachments. --- crates/render/src/inspect.rs | 37 ++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 6c3bd2c0d..91be4de37 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -1166,6 +1166,9 @@ impl<'a> Inspector<'a> { // first of them (§12.5.2 allows one page per annotation). let mut seen = HashSet::new(); let mut kept = 0usize; + // Once the comment budget is spent, comments are no longer built, but links and attachments + // are still listed. + let mut comments_full = false; for (id, page) in pages { let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; if let Ok(Object::Reference(r)) = page_dict.get(b"Annots") @@ -1204,6 +1207,9 @@ impl<'a> Inspector<'a> { { continue; } + if comments_full { + continue; + } let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); let mut color = match d.get(b"C").map(|o| self.resolve(o)) { Ok(Object::Array(c)) if c.len() == 3 => { @@ -1265,13 +1271,11 @@ impl<'a> Inspector<'a> { }; kept = kept.saturating_add(retained(&annotation)); if kept > MAX_ANNOTATION_BYTES { - break; + comments_full = true; + continue; } info.annotations.push(annotation); } - if kept > MAX_ANNOTATION_BYTES { - break; - } } info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); } @@ -2652,6 +2656,31 @@ trailer << /Root 1 0 R >> assert!(notes.iter().map(retained).sum::() <= MAX_ANNOTATION_BYTES); } + /// Links and attachments after the comment budget fills are still listed, and a JavaScript link + /// among them still marks the document. + #[test] + fn links_and_attachments_past_the_comment_budget_are_listed() { + let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 6 0 R >> ".repeat(1100); + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{small} \ + << /Subtype /Link /Rect [0 0 1 1] /A << /S /JavaScript /JS (1) >> >>] >> endobj\n\ + 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\ + << /Subtype /FileAttachment /Rect [0 0 1 1] /FS << /Type /Filespec /F (a.txt) >> >>] >> endobj\n\ + 6 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + "x".repeat(64 << 10), + ); + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; + Inspector::new(&doc).fill(&mut info); + assert!(info.annotations.len() < 1100, "the comment budget filled"); + assert_eq!(info.links.len(), 1); + assert!(info.has_javascript); + assert_eq!(info.attachments.len(), 1); + } + /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. From a887405ea15a459c7f5867c025b26d3afe9f9429 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:07:41 +0000 Subject: [PATCH 120/142] fix(sign): don't report valid when a DSS cap hides revocation evidence The evidence caps counted repeated references to one stream, so a /DSS array could list one benign stream 1024 times ahead of a revoking CRL, and the check never reached it. Repeats are skipped before the entry cap counts anything, and when a cap still stops an array being read in full without a revocation found, the signature's status is unknown rather than valid, with the reason in its details. --- crates/sign/src/pdf.rs | 115 +++++++++++++++++++------ crates/sign/src/pdf_discovery_tests.rs | 12 ++- crates/sign/tests/it/pdf.rs | 31 +++++++ 3 files changed, 126 insertions(+), 32 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 04598e033..f4fe2c601 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -892,7 +892,8 @@ fn finish_validation( // Revocation evidence embedded in the document security store, if any. A verified // revocation is final: later blocks must not soften the verdict. let signer = info.chain.first().cloned(); - let revoked = check_dss_revocation(doc, signer.as_ref(), &at, info); + let revocation = check_dss_revocation(doc, signer.as_ref(), &at, info); + let revoked = revocation == DssRevocation::Revoked; // Changes after signing. info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) { Modification::None @@ -929,6 +930,12 @@ fn finish_validation( problems = true; } } + if revocation == DssRevocation::Incomplete { + if !problems { + info.status = Status::Unknown; + } + problems = true; + } if let Some(t) = info.timestamp_time.or(info.signing_time) && !cert.valid_at(t) { @@ -975,46 +982,86 @@ fn finish_validation( } } -/// Evidence streams read from one `/DSS` array. +/// Distinct evidence streams read from one `/DSS` array. const MAX_DSS_ENTRIES: usize = 1024; /// Decoded bytes read from one `/DSS` array; real CRLs and OCSP responses are kilobytes each. const MAX_DSS_BYTES: usize = 64 << 20; /// The decoded streams of the `/DSS` evidence array `key` (`/CRLs`, `/OCSPs`), one at a time so /// each is checked before the next decodes: each stream once and within the evidence size cap, -/// at most [`MAX_DSS_ENTRIES`] of them and [`MAX_DSS_BYTES`] together. -fn dss_evidence<'a>(doc: &'a Document, dss: &Dict, key: &[u8]) -> impl Iterator> + 'a { - let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let mut items = items.into_iter().take(MAX_DSS_ENTRIES); - let mut seen = HashSet::new(); - let mut left = MAX_DSS_BYTES; - std::iter::from_fn(move || { - for item in items.by_ref() { - if left == 0 { - return None; - } - if item.as_ref().is_some_and(|r| !seen.insert(r)) { +/// at most [`MAX_DSS_ENTRIES`] distinct ones and [`MAX_DSS_BYTES`] together. A stream past a cap +/// sets [`DssEvidence::cut`] and ends the iteration. +struct DssEvidence<'a> { + doc: &'a Document, + items: std::vec::IntoIter, + seen: HashSet, + taken: usize, + left: usize, + /// A cap stopped the array being read in full. + cut: bool, +} + +impl<'a> DssEvidence<'a> { + fn new(doc: &'a Document, dss: &Dict, key: &[u8]) -> Self { + let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + DssEvidence { doc, items: items.into_iter(), seen: HashSet::new(), taken: 0, left: MAX_DSS_BYTES, cut: false } + } +} + +impl Iterator for DssEvidence<'_> { + type Item = Vec; + + fn next(&mut self) -> Option> { + if self.cut { + return None; + } + // Repeats are skipped before the caps count anything, so repeating one stream cannot use + // them up; the scan of the array itself is bounded by the parser's cap on array length. + for item in self.items.by_ref() { + if item.as_ref().is_some_and(|r| !self.seen.insert(r)) { continue; } - let o = doc.resolve(&item); + let o = self.doc.resolve(&item); let Object::Stream(s) = o.as_ref() else { continue }; - let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE.min(left)) else { continue }; - left = left.saturating_sub(bytes.len()); + if self.taken >= MAX_DSS_ENTRIES { + self.cut = true; + return None; + } + self.taken += 1; + // A stream past the evidence cap would not parse as evidence either. + let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE) else { continue }; + let Some(left) = self.left.checked_sub(bytes.len()) else { + self.cut = true; + return None; + }; + self.left = left; return Some(bytes); } None - }) + } +} + +/// What the catalog's `/DSS` says about the signer's certificate. +#[derive(Clone, Copy, Debug, PartialEq)] +enum DssRevocation { + /// Verified evidence revokes it. + Revoked, + /// A cap stopped the evidence being read in full, and what was read does not revoke it. + Incomplete, + /// All of the evidence was read, and none of it revokes it. + NotRevoked, } /// Check the revocation evidence in the catalog's `/DSS` against the signer's chain at `at`. /// Only evidence whose signature verifies against the issuer counts; returns whether the -/// certificate is revoked (which invalidates the signature). -fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Option