From 089acf717d56adbcdabe9234ac6b4460af08842d Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:31:19 +0000 Subject: [PATCH 01/31] M10: keep dotted-leader contents entries together Fixes #1757 --- ROADMAP.md | 2 ++ crates/edit/src/tests.rs | 26 +++++++++++++++ crates/edit/src/text_regions.rs | 56 ++++++++++++++++++++++++++++++++- crates/engine/src/tests.rs | 21 +++++++++++++ docs/roadmap.md | 5 +-- parity/acrobat-features.toml | 4 +-- 6 files changed, 109 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a035c9bf3..714fd1c08 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,6 +148,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-13 (M10, #1757):** Office export keeps each dotted-leader contents entry in one paragraph, with its page number, instead of inferring table cells from its separate text objects. + - **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. - **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged. - **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged. diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index fce7d5e10..3ae55635f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3443,3 +3443,29 @@ fn an_items_measured_face_is_the_face_it_is_written_as() { let courier = |italic| Family::Courier.width("Anna", 12.0, false, italic); assert_eq!(courier(false), courier(true)); } + +#[test] +fn reading_keeps_dotted_leader_entries_whole_and_in_order() { + let mut content = String::new(); + for (title, page, y) in [("Art.1. Scope", "3", 700), ("Art.2. Eligibility", "7", 680), ("Art.3. Application", "12", 660)] { + content.push_str(&format!( + "BT /F1 12 Tf 72 {y} Td ({title}) Tj ET \ + BT /F1 12 Tf 250 {y} Td (....................) Tj ET \ + BT /F1 12 Tf 500 {y} Td ({page}) Tj ET\n" + )); + } + content.push_str("BT /F1 12 Tf 72 640 Td (Appendix) Tj ET BT /F1 12 Tf 250 640 Td (\\205\\205\\205) Tj ET BT /F1 12 Tf 500 640 Td (iv) Tj ET\n"); + content.push_str("BT /F1 12 Tf 72 600 Td (Ordinary body text.) Tj ET BT /F1 12 Tf 72 582 Td (Still the same paragraph.) Tj ET"); + let doc = text_page(&content); + let blocks: Vec<_> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!( + blocks, + [ + "Art.1. Scope .................... 3", + "Art.2. Eligibility .................... 7", + "Art.3. Application .................... 12", + "Appendix ……… iv", + "Ordinary body text. Still the same paragraph.", + ] + ); +} diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 0213d9040..f12a72dd2 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -31,8 +31,62 @@ pub(super) fn group_blocks(lines: &[TextLine]) -> Vec { /// Exporters currently represent a block with one style. Keep their style boundaries until /// they support rich runs, while edit selection may encompass the complete visual region. +/// Dotted-leader entries stay on one row and use the title run's style. pub(super) fn group_reading_blocks(lines: &[TextLine]) -> Vec { - group_layout(lines, false) + let mut blocks = Vec::new(); + let mut start = 0; + let mut offset = 0; + for row in lines.chunk_by(|a, b| same_plane(a, b) && (a.origin.baseline - b.origin.baseline).abs() < a.size.min(b.size) * 0.2) { + let mut text = String::new(); + for (i, line) in row.iter().enumerate() { + if !text.ends_with(char::is_whitespace) + && !line.text.starts_with(char::is_whitespace) + && i.checked_sub(1).and_then(|i| row.get(i)).is_some_and(|prev| line.rect[0] - prev.rect[2] > line.size * 0.12) + { + text.push(' '); + } + text.push_str(&line.text); + } + if leader_entry(&text) + && let Some(first) = row.first() + { + // Tab leaders are a single reading row, even when the title, dots and page + // number are separate text objects far apart. Do not merge neighbouring entries. + blocks.extend(group_layout(lines.get(start..offset).unwrap_or_default(), false)); + let mut line = first.clone(); + line.text = text; + line.rect = row.iter().map(|line| line.rect).reduce(union).unwrap_or(first.rect); + blocks.extend(group_layout(std::slice::from_ref(&line), false)); + start = offset + row.len(); + } + offset += row.len(); + } + blocks.extend(group_layout(lines.get(start..).unwrap_or_default(), false)); + blocks +} + +/// Dotted leaders end in a page number, with a title before the dots. A dot in an article +/// number, or an ellipsis in ordinary prose, is not enough to make a contents entry. +fn leader_entry(text: &str) -> bool { + let text = text.trim_end(); + let prefix = text.trim_end_matches(|c: char| { + c.is_ascii_digit() || matches!(c, 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm' | 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M') + }); + if prefix.len() == text.len() { + return false; + } + let title = prefix.trim_end_matches(|c: char| c.is_whitespace() || matches!(c, '.' | '…')); + let dots = prefix + .get(title.len()..) + .unwrap_or_default() + .chars() + .map(|c| match c { + '.' => 1, + '…' => 3, + _ => 0, + }) + .sum::(); + dots >= 3 && title.chars().any(char::is_alphabetic) } /// Text-space units → user space along the baseline. It is the vertical scale ([`Origin::k`]) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 37cafd440..40c832a46 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4523,3 +4523,24 @@ fn copy_image_finds_a_picture_inside_form_artwork_under_text() { assert_eq!(d.page_image_copy_file(0, 0).unwrap(), ("jpg", jpeg)); assert!(d.page_image_copy_file(0, 1).is_err()); } + +#[test] +fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() { + let mut content = String::new(); + for (title, page, y) in [("Art.1. Scope", "3", 200), ("Art.2. Eligibility", "7", 180), ("Art.3. Application", "12", 160)] { + content.push_str(&format!( + "BT /F1 12 Tf 72 {y} Td ({title}) Tj ET \ + BT /F1 12 Tf 250 {y} Td (....................) Tj ET \ + BT /F1 12 Tf 500 {y} Td ({page}) Tj ET\n" + )); + } + let pdf = one_line_page("/Font << /F1 4 0 R >>", &content); + let mut session = Session::new(); + let id = session.open("contents.pdf", None, Arc::new(pdf), None).unwrap(); + let doc = session.get(id).unwrap(); + let html = String::from_utf8(doc.export_office(compare::OfficeFormat::Html)).unwrap(); + assert!(!html.contains(""), "contents entries are paragraphs: {html}"); + let entries = ["Art.1. Scope .................... 3", "Art.2. Eligibility .................... 7", "Art.3. Application .................... 12"]; + let positions: Vec<_> = entries.iter().map(|entry| html.find(&format!("

{entry}

")).expect("one paragraph per entry")).collect(); + assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}"); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 04103fb63..43e4978ed 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M10: dotted-leader contents entries export as complete paragraphs; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -109,7 +109,7 @@ total matches the parity estimate. | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Vertical-writing text is still refused, and the error no longer claims the text was under the mark (#1340). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | @@ -161,6 +161,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | M10: dotted-leader contents entries retain titles and page numbers in separate paragraphs (#1757); estimates unchanged. | | 2026-10-12 | minor | M13: CLI check directory identity, with overlap and symlink regressions; estimates unchanged | | 2026-10-12 | minor | M4: shared Type 3 glyph resources survive extraction, with save/reopen and independent raster regressions; estimates unchanged | | 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8f4194b93..ad5a29acf 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6952,8 +6952,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["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::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"] -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, per-page sections/sizes, 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; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. 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." +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::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"] +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, per-page sections/sizes, 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. 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]] id = "create.export-rtf" From 9d31cf9f99fa0f4a9819bc22caac42dc769bef7d Mon Sep 17 00:00:00 2001 From: arpan-star Date: Wed, 14 Oct 2026 20:28:02 +0530 Subject: [PATCH 02/31] fix(docs): correct spelling of "Formatting" in comments --- vendor/lopdf/examples/merge.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/lopdf/examples/merge.rs b/vendor/lopdf/examples/merge.rs index 151440c84..0fee85b51 100644 --- a/vendor/lopdf/examples/merge.rs +++ b/vendor/lopdf/examples/merge.rs @@ -92,7 +92,7 @@ fn main() { )); // Can set bookmark formatting and color per report bookmark added. - // Formating is 1 for italic 2 for bold 3 for bold and italic + // Formatting is 1 for italic 2 for bold 3 for bold and italic // Color is RGB 0.0..255.0 for (layer, mut doc) in documents { let color = [0.0, 0.0, 0.0]; From cdbce8ac176490f2b5680db89ea9c52ab45f1071 Mon Sep 17 00:00:00 2001 From: Nick Nyssen <314538108+amoisttowe1@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:25:24 -0700 Subject: [PATCH 03/31] crypt: authenticate against encryption numbers we did not compute Adds crates/crypt/tests/qpdf_literals.rs (305 lines): three tests that hand our security handler /O, /U, /OE, /UE and /Perms values from outside implementations, then require our reader to authenticate them and report the intended role. Tests only - no product code, no new dependency; PDFs stay outside the repository and only quoted hex is committed. Why outsiders wrote these numbers: create() and open() share one derivation, so a wrong-but-self-consistent implementation passes every internal test. Two review rounds proved it by mutation - several key-derivation breaks stayed green in both suites because writer and reader drifted together. Quoting bytes we did not compute breaks the circle: open() recomputes /U (or the revision 6 hash chain) and compares it to what was stored, so a divergent derivation cannot authenticate. Six cases come from qpdf core 12.2.0 via pikepdf (R2/R3, R4 in both metadata modes, R6 in both). Revisions 2-4 are deterministic given /ID and passwords, so the generator reproduces their quoted bytes byte for byte (verified). Revision 6 salts are fresh on every run by design: these literals match the fixtures as captured, and re-running the generator will produce different - equally valid - revision 6 numbers, so compare R/V/Length structurally there and re-quote if you regenerate. One case is HAND-DERIVED, labelled as such in the source: an independent re-implementation of ISO Algorithms 2/3 at n = 5, cross-validated by having that same routine reproduce qpdf's n=16 /O byte for byte. It exists because no conforming producer emits R3 at 40 bits - qpdf writes 128 there and this crate's create() maps Rc4_40 to revision 2 - yet the derivation loop only runs for r >= 3, so without a crafted row its truncation is unobservable and mutating it changes behaviour that nothing catches. Measured effect, same battery, upstream --lib run separately each time: r6-hash-selector (%3 collapsed to %2) CAUGHT here; upstream stays green r6-sha-arms-swapped (SHA-384 <-> SHA-512) CAUGHT here; upstream stays green r6-selector-window (sums 17 bytes not 16) CAUGHT here; upstream stays green r4-key-chain-truncation (loop hashes whole digest) CAUGHT here, via the crafted row; upstream stays green. Pristine 3/3 pass, mutated 2 passed / 1 FAILED - verified twice. Still invisible, named rather than glossed: r6-round-floor (minimum rounds 64 -> 63) - the terminator does not separate the two floors on these inputs; /OE,/UE length loosening and the /Perms filler source, both unreached here. Corrections carried from review: qpdf's advice against a static /ID does not weaken these revision 6 vectors, because key_r6 never receives the file /ID at all; it matters only to the revision 2-4 paths, where we quote /ID literally so the numbers stay exact. And no ATTRIBUTION entry is added, matching crates/crypt/src/tests.rs:237 which already quotes qpdf vectors unattributed; cargo xtask assets stays ok. Gates on a clean tree: cargo test -p pdfcraft-crypt 28 passed / 0 failed (17 lib + the 8 properties from #2358, now merged, plus 3 oracle tests - one per concern - role authentication across seven dictionaries, metadata-flag key separation, /Perms decode); clippy --all-targets -D warnings clean; cargo fmt --check clean; xtask layers OK (35 crates). Integrity note: the literals in this commit were checked field by field against the PDFs on disk - 27 of 27 fields quoted verbatim, no transcription drift. A regression test for that check belongs in the generator's output comparison rather than here, since it needs pikepdf. --- crates/crypt/tests/qpdf_literals.rs | 305 ++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 crates/crypt/tests/qpdf_literals.rs diff --git a/crates/crypt/tests/qpdf_literals.rs b/crates/crypt/tests/qpdf_literals.rs new file mode 100644 index 000000000..01e341fd8 --- /dev/null +++ b/crates/crypt/tests/qpdf_literals.rs @@ -0,0 +1,305 @@ +//! Encryption numbers written by an **outside implementation**: qpdf core 12.2.0 (through pikepdf +//! 9.11) built each fixture, and the `/O`, `/U`, `/OE`, `/UE`, `/Perms` and `/ID` values quoted below +//! were copied out of those files rather than computed here. +//! +//! That distinction is the whole point. `create()` and `open()` share one derivation, so a wrong-but +//! self-consistent implementation passes every internal test: two review rounds proved it by breaking the +//! key derivation in ten different ways and watching both suites stay green. Quoting an outsider's bytes +//! breaks the circle - `open()` recomputes `/U` (or the revision 6 hash chain) and compares it to what +//! qpdf stored, so a derivation that diverges cannot authenticate at all. +//! +//! Revisions covered: 2 (export-grade RC4, where the five-byte key makes a truncation error visible that +//! every existing R3/R4 vector in this repository hides as a no-op), 3, and 4 and 6 each with metadata +//! encrypted *and* plain - which is the only way to see Algorithm 2 step f's four `FF` bytes from +//! outside. Regenerate with `make-oracle-fixtures.py` in the workspace root (needs `.dc-tools/`, which +//! lives outside this repository); the PDFs themselves are never committed, only these quoted numbers. + +use pdfcraft_crypt::{Auth, EncryptDict, Method, SecurityHandler, aes_cbc_decrypt}; + +const USER: &str = "open pw"; +const OWNER: &str = "owner pw"; + +/// One file qpdf wrote. Bytes are held as hex so the table stays a `const`: the literals on screen are +/// then exactly what qpdf emitted, with no runtime step in between to trust. +struct Case { + label: &'static str, + v: i64, + r: i64, + length_bits: i64, + p: i32, + encrypt_metadata: bool, + id0_hex: &'static str, + o_hex: &'static str, + u_hex: &'static str, + oe_hex: &'static str, + ue_hex: &'static str, + perms_hex: &'static str, + crypt_filters: fn() -> Vec<(Vec, Method)>, + stm_f: &'static str, + str_f: &'static str, + key_len_bytes: usize, +} + +impl Case { + fn dict(&self) -> EncryptDict { + EncryptDict { + filter: b"Standard".to_vec(), + v: self.v, + r: self.r, + length_bits: self.length_bits, + o: hex(self.o_hex), + u: hex(self.u_hex), + oe: hex(self.oe_hex), + ue: hex(self.ue_hex), + perms: hex(self.perms_hex), + p: self.p, + encrypt_metadata: self.encrypt_metadata, + crypt_filters: (self.crypt_filters)(), + stm_f: self.stm_f.as_bytes().to_vec(), + str_f: self.str_f.as_bytes().to_vec(), + ef_f: Vec::new(), + } + } + + fn id0(&self) -> Vec { + hex(self.id0_hex) + } +} + +fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +fn no_filters() -> Vec<(Vec, Method)> { + Vec::new() +} + +fn aes128_filter() -> Vec<(Vec, Method)> { + vec![(b"StdCF".to_vec(), Method::Aes128)] +} + +fn aes256_filter() -> Vec<(Vec, Method)> { + vec![(b"StdCF".to_vec(), Method::Aes256)] +} + +/// `/ID[0]` is shared by every case because qpdf wrote them with `static_id`. +const CASES: &[Case] = &[ + Case { + label: "r2-40bit", + v: 1, + r: 2, + length_bits: 40, + p: -28, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "166a894580aae60ae93fa9d83fc576f26f13dbe4574ce6c57497bec3ece7f125", + u_hex: "69ff2fb98261fd094174f93f0388859f356080ca486a38ce2d5ac23b98f5f89c", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: no_filters, + stm_f: "", + str_f: "", + key_len_bytes: 5, + }, + Case { + label: "r3-128rc4", + v: 2, + r: 3, + length_bits: 128, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848", + u_hex: "1b4b8fb3fd213b4eadc142ab9f385fc80122456a91bae5134273a6db134c87c4", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: no_filters, + stm_f: "", + str_f: "", + key_len_bytes: 16, + }, + Case { + label: "r4-aes128-meta-enc", + v: 4, + r: 4, + length_bits: 128, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848", + u_hex: "1b4b8fb3fd213b4eadc142ab9f385fc80122456a91bae5134273a6db134c87c4", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: aes128_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 16, + }, + Case { + label: "r4-aes128-meta-plain", + v: 4, + r: 4, + length_bits: 128, + p: -1044, + encrypt_metadata: false, + id0_hex: "31415926535897932384626433832795", + o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848", + u_hex: "d6d363fc817815701f0242a581cb39e60122456a91bae5134273a6db134c87c4", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: aes128_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 16, + }, + Case { + label: "r6-aes256-meta-enc", + v: 5, + r: 6, + length_bits: 256, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "1c791d66227fdfd7c1d225eb81fb3408d8bcfac1a0ff71cc57b10eb29029e5f6965cccb6b94e461d9da354a9dbd6eea7", + u_hex: "64ffbf79168ed7b325267cdba8dae211d4c2c5ebdffd339c7ac36c33e26f23b7f478c60ae42d50b5d20ceac56d659151", + oe_hex: "d2f6ef0bcc6ce93228be760c7eee8f4259af7a6044a4c4e04a0d48ab0893ef1e", + ue_hex: "963035aa24fb009e6d26b3cffd31492ffe6f4e563e4dcae8d7b7e3203c4a2f56", + perms_hex: "5675a86560873d2532e856a37045247e", + crypt_filters: aes256_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 32, + }, + Case { + label: "r6-aes256-meta-plain", + v: 5, + r: 6, + length_bits: 256, + p: -1044, + encrypt_metadata: false, + id0_hex: "31415926535897932384626433832795", + o_hex: "e227e586c4dc5a362c7271d960565cf3a37145e33af5bf5a08c73b52a1d819e152c737176dc08774e400798d6534e95e", + u_hex: "dbb84b847bb2d80865cc3c5a7c751d4c659d2c96ed1750f8be6f9c3b93fe3d11f494b43e5f17a21c85f5a4b171055afb", + oe_hex: "8c31f2392bc2a093850269a3404cbe01cea38e5e0fe308fe8ac50f5222cff103", + ue_hex: "2b0c44e4bddb3c9c01431b4ed3c7da42dbd1ef47e610d626ec2124535a0a4469", + perms_hex: "adbb69fb9fffde31ba90aaaf5eb4bdaf", + crypt_filters: aes256_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 32, + }, + // HAND-DERIVED, not produced by qpdf: an independent re-implementation of ISO Algorithms 2/3 + // (n = 5) computed this pair, cross-validated by having the same routine reproduce qpdf's + // n = 16 `/O` byte for byte. It exists because no conforming producer emits R3 at 40 bits - + // qpdf always writes 128 there, and this crate's own create() maps Rc4_40 to revision 2 - so + // the only way to reach `key_len` = 5 with the derivation loop RUNNING (r >= 3) is to craft + // it. With that combination live, the loop's truncation stops being a no-op: mutating + // `Md5::digest(&digest[..n])` to hash the whole digest makes this password stop authenticating. + Case { + label: "crafted-r3-40bit-hand-derived", + v: 2, + r: 3, + length_bits: 40, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "50e50251fd20acb28ba50013142d19a47249d06b977d28b63cb3ce9d56fb5d50", + u_hex: "000756f24fe648256830aec6155ca6f100000000000000000000000000000000", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: no_filters, + stm_f: "", + str_f: "", + key_len_bytes: 5, + }, +]; + +#[test] +fn outside_derived_dictionaries_authenticate_with_the_intended_role() { + for case in CASES { + let d = case.dict(); + let id0 = case.id0(); + + // The user password must open as a user. Owners are tried first, so an `Owner` here would mean + // the two passwords had collapsed onto one set of bytes - on these spellings they have not. + let user = SecurityHandler::open(d.clone(), &id0, Some(USER)) + .unwrap_or_else(|e| panic!("{}: qpdf's user password was refused by our reader: {e}", case.label)); + assert_eq!(user.auth(), Auth::User, "{}", case.label); + + // The owner password must open as owner: revisions 2-4 recover the user password from `/O` + // (Algorithm 7), revision 6 hashes against qpdf's own `/O`. + let owner = + SecurityHandler::open(d.clone(), &id0, Some(OWNER)).unwrap_or_else(|e| panic!("{}: qpdf's owner password was refused: {e}", case.label)); + assert_eq!(owner.auth(), Auth::Owner, "{}", case.label); + + // A wrong password may never open it; and the key must be exactly as wide as the revision + // promises - those five bytes are what makes a revision 2 truncation detectable at all. + assert!(SecurityHandler::open(d.clone(), &id0, Some("not the password")).is_err(), "{}: a wrong password opened", case.label); + assert_eq!(user.file_key().len(), case.key_len_bytes, "{}: file key width", case.label); + assert_eq!(owner.file_key(), user.file_key(), "{}: whichever password opens it, one key", case.label); + + // A user session must not claim owner rights. We deliberately do NOT assert individual + // permission flags here: which bit means "extract" moves between revision 2 and revisions 4 + // (qpdf wrote `/P = -28` for the export-grade file, where our accessor's bit-10 reading of + // "extract for accessibility" is a reserved field), and pinning that mapping belongs with the + // permissions tests, not with this oracle. What matters here is only that authentication, role + // and key width agree with what an outside implementation produced. + assert!(!user.permissions().owner, "{}: a user session claimed owner rights", case.label); + } +} + +#[test] +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 - invisible to every internal test, because all + // of them build metadata-encrypted files. + 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"); + let b = CASES.iter().find(|c| c.label == plain_label).expect("plain-metadata fixture present"); + + let ha = SecurityHandler::open(a.dict(), &a.id0(), Some(USER)).unwrap_or_else(|e| panic!("{enc_label}: refused by our reader: {e}")); + let hb = SecurityHandler::open(b.dict(), &b.id0(), Some(USER)) + .unwrap_or_else(|e| panic!("{plain_label}: refused by our reader - is step f being ignored? {e}")); + + assert_ne!(ha.file_key(), hb.file_key(), "{enc_label} and {plain_label} must not share a key: the metadata flag is hashed in"); + assert!(ha.encrypts_metadata(), "{enc_label} reports encrypted metadata"); + assert!(!hb.encrypts_metadata(), "{plain_label} reports plain metadata, as qpdf wrote it"); + } + + // The outsider's numbers must differ across revisions; equality here would mean a transcription + // error, which would quietly void every assertion above. + let r2 = CASES.iter().find(|c| c.r == 2).unwrap(); + let r3 = CASES.iter().find(|c| c.r == 3).unwrap(); + assert_ne!(hex(r2.u_hex), hex(r3.u_hex), "revision 2 and revision 3 /U differ because the key width differs"); + assert_eq!(r2.u_hex.len(), 64, "revision 2 /U is RC4 over the padding: 32 bytes"); + assert_eq!(r3.u_hex.len(), 64, "revision 3 /U is sixteen bytes plus sixteen reserved zeros"); +} + +#[test] +fn qpdf_perms_decode_to_the_shape_both_implementations_agree_on() { + // `/Perms` is the one value in this dictionary that no test here verified before: `cos` writes it + // out and reads it back, but nothing decrypts it. Under qpdf's own bytes we can - `/P` + // little-endian, four set bytes, `T`/`F`, the `adb` sentinel (Algorithm 10 here; vendored lopdf + // checks the same fields as its Algorithm 13, though only three bytes of `/P`). + let mut checked = 0; + for case in CASES.iter().filter(|c| !c.perms_hex.is_empty()) { + let h = SecurityHandler::open(case.dict(), &case.id0(), Some(USER)) + .unwrap_or_else(|e| panic!("{}: refused before /Perms could be read: {e}", case.label)); + let open = aes_cbc_decrypt(h.file_key(), &[0u8; 16], &hex(case.perms_hex), false); + + assert_eq!(open.len(), 16, "{}: /Perms is one encrypted block", case.label); + assert_eq!(&open[..4], &case.p.to_le_bytes(), "{}: /P must round trip against qpdf's bytes", case.label); + assert_eq!(&open[4..8], &[0xFF; 4], "{}: the four bytes after /P are set", case.label); + assert_eq!(open[8], if case.encrypt_metadata { b'T' } else { b'F' }, "{}: metadata byte as qpdf wrote it", case.label); + assert_eq!(&open[9..12], b"adb", "{}: sentinel from qpdf's bytes", case.label); + assert_ne!(&open[12..16], &[0u8; 4], "{}: qpdf's filler is not frozen at zero", case.label); + checked += 1; + } + assert!(checked >= 2, "expected /Perms on both revision 6 cases; saw {checked}"); +} From 2ec501fa7406d7ea87d2e35e5ec0521d21c3afaf Mon Sep 17 00:00:00 2001 From: Nick Nyssen <314538108+amoisttowe1@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:41:23 -0700 Subject: [PATCH 04/31] test(cos): property tests for save fidelity, plus credit entry Adds crates/cos/tests/proptests.rs (464 lines) - three properties over generated catalogs (edit sequence vs a plain ordered model; full save in both output styles; incremental save over bases built BOTH ways) - and a self-credit entry in contributors/people.toml committed under the author's own identity. No product code changes; total +466 lines across two files: people.toml +2; proptests.rs +464. Two design decisions that came out of adversarial review, each measured rather than asserted: * catalog keys insert in a scrambled order ([5,0,7,2,6,1,4,3] filtered to the drawn keys), because with ascending insertion "preserves key order" and "sorted by key" are the same observable. * incremental expectations derive from the values written, not from reading the writer's own output back, so a consistently-wrong implementation cannot pass by agreeing with itself. Measured against one-line breaks in crates/cos/src/writer.rs, each reverted and byte-compared afterwards (verdicts from cargo exit codes; "pre-existing" = lib 48 + writer 10 + encryption 8 + shared_streams 3 + bounded_writer 4, i.e. every target that existed before this PR): sort every dictionary by key CAUGHT (props 1+2) pre-existing suite FULLY GREEN reverse dictionary emission CAUGHT (all three props) pre-existing suite FULLY GREEN move /Type to the end of every dict CAUGHT (prop 1) pre-existing suite FULLY GREEN - caught only after catalog_keys() was added to pin the full key sequence; without it this break was green everywhere classic-table incremental startxref +1 CAUGHT (prop 2 alone; props 1+3 green), failure message names "classic-table", confirming the incremental table path executes drop null-valued dictionary entries CAUGHT (all three) at the pre-fix commit b51381c this same break left property 2 green - the self-reference it removed Known limits, stated not hidden: incremental saves are asserted to add bytes and leave the original prefix untouched - minimality is NOT claimed; catalog key order pins one fixed scramble rather than drawn permutations, so 25.4% of cases draw too few keys for order to be meaningful; nested dictionary key order is deliberately not pinned (canon sorts inside values); /Perms and permissions semantics are out of scope here. Gates at this tip (upstream 24f93e6, drift 0): cargo test -p pdfcraft-cos 85 passed / 0 failed / 2 ignored; cargo fmt -p pdfcraft-cos --check clean; clippy exit 0 for this crate with -D warnings -A clippy::chunks_exact_to_as_chunks (that lint fires only in upstream's own crates/oracle/src/pixels.rs:189 from f988cf8, tracked by #2342/#2324; flags after `--` forward into every crate in the build graph, and crates/cos itself emits zero warnings under a forced recompile). cargo xtask layers OK: 37 crates. Scope: part of #1187 (items 1-3 partly; the reference-following comparison, undo state machine, password property and damaged-file splices remain open). The encryption-side properties are PR #2358 by the same author; earlier drafts of this text mis-cited #2014, which is certificate encryption and still open. --- contributors/people.toml | 2 + crates/cos/tests/proptests.rs | 464 ++++++++++++++++++++++++++++++++++ 2 files changed, 466 insertions(+) create mode 100644 crates/cos/tests/proptests.rs diff --git a/contributors/people.toml b/contributors/people.toml index da3cfc6a7..d1267320d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -145,3 +145,5 @@ sync_github_name = false [people.Silvertree2010] display_name = "Netter" sync_github_name = false +[people.amoisttowe1] +real_name = "Nick Nyssen" diff --git a/crates/cos/tests/proptests.rs b/crates/cos/tests/proptests.rs new file mode 100644 index 000000000..4380dd0bd --- /dev/null +++ b/crates/cos/tests/proptests.rs @@ -0,0 +1,464 @@ +//! Property tests for `pdfcraft-cos` save fidelity — proptest coverage of two areas raised in +//! #1187. **This does not close #1187.** That issue asks for five Hegel properties; this adds +//! save/reopen and edit-history coverage using `proptest`, which is already a dev-dependency of this +//! crate (`Cargo.toml:20`), so no new dependency is needed to review it. Left for follow-ups: +//! #1187's reference-following comparison, its snapshot/undo state machine, the password property (the +//! one whose pilot found #1185) and its damaged-file byte splices. The object-level serialise → parse +//! round trip it also lists already exists inline in `src/writer.rs`. +//! +//! Covered: +//! +//! 1. **A full save keeps every value it wrote into the catalog**, in both output styles (objects +//! packed into object streams, §7.5.7; the classic cross-reference table otherwise), keeps the +//! catalog's own structure intact (`/Type /Catalog`, and a `/Pages` node whose `/Kids` array and +//! `/Count` agree — the fixture's tree is empty, so this pins shape and agreement, not rendered +//! pages), preserves dictionary +//! key order (which `README.md:75` promises is byte-stable), emits spec-shaped §7.5.4 cross-reference +//! rows, and reopens with nothing to repair. +//! 2. **An incremental save is a strict append**: the original bytes come out untouched at the front +//! (§7.5.6), the file keeps two revisions whose first still points at the base cross-reference and +//! opens standalone, the edit is visible while every existing value stays put in order, and it +//! reopens with nothing to repair. +//! 3. **A write/delete sequence equals a plain map**, checked after every step and again once saved and +//! reopened. A deleted key must stay gone — not return its old value, not read as null either. +//! Deletes are generated against keys that were just written, so the delete path cannot be skipped. +//! +//! ## Scope, stated honestly +//! Generated values are pure data — no streams and no indirect references — so this says nothing about +//! stream payloads or the renumbering a full save legitimately performs. Strings are compared as bytes, +//! so literal ↔ hexadecimal re-emission is not treated as a change (§7.3.6); the *choice* of spelling +//! is therefore not asserted, and `PdfString.hex` is not pinned here. Dictionary keys at the catalog +//! level *are* compared in order, so reversing them fails; nested dictionary order inside a value is +//! sorted away by [`canon`] and is not pinned. Generated reals stay within ±1e6: `write_real` +//! (`src/writer.rs:46`) turns a non-finite real into `0` and an integral real below 1e15 into integer +//! syntax, so [`canon`] applies the writer's documented normalisation to **both** sides rather than +//! forgiving a mismatch afterwards. + +use std::sync::Arc; + +use proptest::prelude::*; +use proptest::test_runner::TestCaseError; + +use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full, write_incremental}; + +/// The prefix every generated key carries. `Document::new_empty` puts `/Type` and `/Pages` in the +/// catalog; property 1 asserts those two structurally rather than ignoring them. +const KEY: &str = "px"; + +/// Generated keys go into the catalog in this deliberately unsorted sequence. Ascending insertion would make +/// "preserves dictionary key order" and "every dictionary sorted by key" the same observable, so a writer +/// that silently sorted would rewrite every saved byte and stay green. This scramble catches sorting of the +/// keys it names; it does NOT on its own pin where structural entries like `/Type` sit, because `saved` +/// filters to the `px` prefix - that gap is closed separately by [`catalog_keys`] asserting the full +/// sequence. Nested dictionary order is deliberately not pinned (see `canon`). Every permutation of the +/// generated keys is NOT drawn here either: one fixed order per case, chosen to make sorting detectable. +const INSERTION_ORDER: [usize; 8] = [5, 0, 7, 2, 6, 1, 4, 3]; + +/// A value reduced to a comparable spelling, with the writer's documented normalisations applied. +fn canon(o: &Object) -> String { + match o { + Object::Null => "null".into(), + Object::Bool(b) => format!("b{b}"), + Object::Int(i) => format!("i{i}"), + Object::Real(r) => { + if !r.is_finite() { + "i0".into() + } else if r.fract() == 0.0 && r.abs() < 1e15 { + format!("i{}", *r as i64) + } else { + format!("r{r}") + } + } + Object::Name(n) => format!("n{}", String::from_utf8_lossy(n)), + // Bytes, because a literal may be re-emitted as hexadecimal or the reverse; both spellings + // denote the same bytes (§7.3.6). The `hex` flag itself is deliberately not compared — see + // the scope note at the top. + Object::String(s) => format!("s{}", s.bytes.iter().map(|b| format!("{b:02x}")).collect::()), + Object::Array(a) => format!("[{}]", a.iter().map(canon).collect::>().join(",")), + Object::Dict(d) => { + // Sorted inside a value: nested key order is not pinned here. Catalog-level order is, and + // it is asserted in iteration order by `saved`. + let mut pairs: Vec<(String, String)> = d.iter().map(|(k, v)| (String::from_utf8_lossy(k).into_owned(), canon(v))).collect(); + pairs.sort(); + format!("{{{}}}", pairs.iter().map(|(k, v)| format!("{k}=>{v}")).collect::>().join(",")) + } + // Unreachable under the generators below, which build no streams and no references. Kept total + // so this compiles if someone widens them — at which point it should compare decoded bytes and + // resolved targets instead of a constant. + Object::Stream(_) => "stream".into(), + Object::Ref(r) => format!("ref{r}"), + } +} + +fn leaf() -> impl Strategy { + prop_oneof![ + Just(Object::Null), + any::().prop_map(Object::Bool), + (-1_000_000i64..1_000_000i64).prop_map(Object::Int), + (-1e6f64..1e6f64).prop_map(Object::Real), + // Non-finite reals, drawn explicitly. `write_real` (`src/writer.rs:46`) replaces them with + // `0`, and [`canon`] spells both sides that way, so this is what makes the normalisation + // described in the module header actually exercised rather than merely asserted in prose. + prop::sample::select(vec![f64::NAN, f64::INFINITY, f64::NEG_INFINITY]).prop_map(Object::Real), + // Raw bytes, including ones a literal string must escape and the NUL byte. + prop::collection::vec(any::(), 0..12).prop_map(|b| Object::String(PdfString::literal(b))), + "[A-Za-z][A-Za-z0-9]{0,6}".prop_map(|s| Object::name(&s)), + ] +} + +/// Nested values to a bounded depth, so generation cannot recurse without limit. +fn value(depth: usize) -> BoxedStrategy { + if depth == 0 { + return leaf().boxed(); + } + prop_oneof![ + leaf().boxed(), + prop::collection::vec(value(depth - 1), 0..5).prop_map(Object::Array).boxed(), + prop::collection::btree_map("[a-z]{1,4}", value(depth - 1), 0..5) + .prop_map(|m| { + let mut d = Dict::new(); + for (k, v) in m { + d.set(k.into_bytes(), v); + } + Object::Dict(d) + }) + .boxed(), + ] + .boxed() +} + +fn find(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()).position(|w| w == needle) +} + +/// Every catalog key, in the dictionary's own order - structural entries included. `saved` filters to +/// `px*`, which is why sorting of generated keys is visible there but `/Type` moving to the end was not; +/// this covers that. Read from memory before a save and from the parsed file afterwards, so the two sides +/// share only `Dict` iteration, not the serialiser under test. +fn catalog_keys(doc: &Document) -> Vec { + let root = match doc.root() { + Some(r) => r, + None => return Vec::new(), + }; + match doc.get(root).as_dict() { + Some(d) => d.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect(), + None => Vec::new(), + } +} + +/// The catalog's `px*` entries in **iteration order**, so a reordered save fails. +fn saved(doc: &Document) -> Result, TestCaseError> { + let root = doc.root().ok_or_else(|| TestCaseError::fail("no catalog in the saved file"))?; + let cat = doc.get(root); + let d = match cat.as_dict() { + Some(d) => d, + None => return Err(TestCaseError::fail("catalog is not a dictionary")), + }; + Ok(d.iter().filter(|(k, _)| k.starts_with(KEY.as_bytes())).map(|(k, v)| (String::from_utf8_lossy(k).into_owned(), canon(v))).collect()) +} + +/// The catalog's structure, which a reader needs whether or not it holds our test keys. +fn assert_catalog_structure(doc: &Document, style: &str) -> Result<(), TestCaseError> { + let root = doc.root().ok_or_else(|| TestCaseError::fail(format!("no catalog after the {style} save")))?; + let cat = doc.get(root); + let d = match cat.as_dict() { + Some(d) => d, + None => return Err(TestCaseError::fail(format!("catalog is not a dictionary after the {style} save"))), + }; + prop_assert_eq!(d.name(b"Type"), Some(&b"Catalog"[..]), "catalog /Type changed on the {} save", style); + let pages = match d.get(b"Pages") { + Some(p) => p, + None => return Err(TestCaseError::fail(format!("catalog lost /Pages on the {style} save"))), + }; + let resolved = doc.resolve(pages); + let pd = match resolved.as_dict() { + Some(d) => d, + None => return Err(TestCaseError::fail(format!("/Pages does not resolve to a dictionary on the {style} save"))), + }; + prop_assert_eq!(pd.name(b"Type"), Some(&b"Pages"[..]), "/Pages /Type changed on the {} save", style); + let kids = match pd.get(b"Kids").and_then(Object::as_array) { + Some(k) => k, + None => return Err(TestCaseError::fail(format!("/Pages /Kids is missing or not an array on the {style} save"))), + }; + // /Count has to agree with /Kids. A page tree whose count lies still opens, then misnumbers every + // page downstream, so checking presence alone would not be enough. This fixture's tree is empty, so + // what is pinned here is shape and agreement — not the existence of rendered pages. + prop_assert_eq!(pd.int(b"Count"), Some(kids.len() as i64), "/Pages /Count disagrees with its {} kids on the {} save", kids.len(), style); + Ok(()) +} + +/// §7.5.4: every entry of a classic cross-reference table is exactly 20 bytes, closed by a two-byte +/// end of line. Unpadded offsets still open in lenient readers and corrupt strict ones, so the byte +/// shape is asserted rather than inferred from a successful reopen. +fn assert_classic_xref_rows(bytes: &[u8]) -> Result<(), TestCaseError> { + // Anchored on the file's own startxref (§7.5.3) rather than the first "xref" text in the buffer: + // a generated document name could otherwise point the scan at prose. A startxref that does not land + // on the `xref` keyword means a cross-reference stream, which has no table to check here. + let Some(xoff) = startxref(bytes) else { return Err(TestCaseError::fail("no readable startxref in a classic-table save")) }; + let x = xoff as usize; + if bytes.get(x..x + 4) != Some(&b"xref"[..]) { + return Ok(()); + } + let nl = match bytes[x..].iter().position(|b| *b == b'\n') { + Some(n) => n, + None => return Err(TestCaseError::fail("xref keyword not followed by a newline")), + }; + let rest = &bytes[x + nl + 1..]; + let end = match find(rest, b"trailer") { + Some(e) => e, + None => return Err(TestCaseError::fail("cross-reference table with no trailer")), + }; + let mut rows = 0usize; + let mut expected_rows = 0usize; + for line in rest[..end].split(|b| *b == b'\n') { + if line.is_empty() { + continue; + } + // A CR before the line feed belongs to the two-byte terminator (§7.5.4), not to the keyword: + // stripped here so a CR+LF row is still recognised as a legal row rather than reported (wrongly) + // as "no cross-reference rows found". Producers do emit CR+LF terminators. + // + // What catches what: classification keys off the keyword, so a row whose keyword is garbage never + // reaches the width and length asserts below. Those rows are named by the `else` arm at the end + // of this loop; the row-count compare after it is only a backstop. The length assert is not the + // safety net for malformed keywords, and this comment used to imply that it was. + let fields: Vec<&[u8]> = line + .split(|b| *b == b' ') + .filter(|f| !f.is_empty()) + .map(|f| match f.last() { + Some(b'\r') => &f[..f.len() - 1], + _ => f, + }) + .collect(); + if fields.len() == 2 && fields.iter().all(|f| f.iter().all(u8::is_ascii_digit)) { + // ` ` subsection header. + expected_rows += std::str::from_utf8(fields[1]).ok().and_then(|s| s.parse::().ok()).unwrap_or(0); + continue; + } + if fields.len() == 3 && matches!(fields[2], b"n" | b"f") { + // Classified by keyword, not by width: an unpadded row must still be recognised as a row, so + // the failure names the real crime (wrong width) instead of the misleading "no rows found". + // Both keywords are legal — `n` in use, `f` free (the head of the free chain carries + // generation 65535). §7.5.4 fixes each field's width and the whole entry at 20 bytes. + prop_assert_eq!( + fields[0].len(), + 10, + "a cross-reference offset must be 10 digits (§7.5.4), got {:?} in row {:?}", + String::from_utf8_lossy(fields[0]), + String::from_utf8_lossy(line) + ); + prop_assert_eq!( + fields[1].len(), + 5, + "a cross-reference generation must be 5 digits (§7.5.4), got {:?} in row {:?}", + String::from_utf8_lossy(fields[1]), + String::from_utf8_lossy(line) + ); + // Exactly 20 bytes per entry in the file (§7.5.4). Both legal terminators land on 19 bytes + // for the line as split here: SP+LF, which is what this writer emits today, and CR+LF, whose + // terminator is the pair itself. A 21-byte entry (SP+CRLF, or a doubled CR) fails here. + prop_assert_eq!( + line.len(), + 19, + "a cross-reference row is {} bytes; §7.5.4 wants exactly 20 in the file (SP+LF or CR+LF): {:?}", + line.len(), + String::from_utf8_lossy(line) + ); + rows += 1; + } else { + // Inside the table region, a line is either a subsection header or an entry. Anything else is + // named here with its bytes quoted, instead of surfacing later as a bare row-count mismatch + // ("no cross-reference rows found") that blames the table for one bad line. + prop_assert_eq!( + fields.len(), + 3, + "unrecognised line in the cross-reference table, want ` n|f`: {:?}", + String::from_utf8_lossy(line) + ); + prop_assert!( + matches!(fields[2], b"n" | b"f"), + "illegal cross-reference keyword {:?} in row {:?}", + String::from_utf8_lossy(fields[2]), + String::from_utf8_lossy(line) + ); + } + } + prop_assert!(rows > 0, "no cross-reference rows found in a classic-table save"); + prop_assert_eq!(rows, expected_rows, "the subsection headers promised {} rows, the table holds {}", expected_rows, rows); + Ok(()) +} + +/// The `startxref` value of a file, which the next incremental update must point back at via `/Prev`. +/// The keyword is followed by whitespace, then the offset (§7.5.3), so leading whitespace is skipped. +fn startxref(bytes: &[u8]) -> Option { + let at = find(bytes, b"startxref")?; + let tail = &bytes[at + 9..]; + let digits: String = tail.iter().skip_while(|b| b.is_ascii_whitespace()).take_while(|b| b.is_ascii_digit()).map(|b| char::from(*b)).collect(); + digits.parse().ok() +} + +proptest! { + // 256 cases per property; the suite runs in well under a second, so coverage is free here. + #![proptest_config(ProptestConfig::with_cases(256))] + + /// 1. A full save keeps the values it wrote, their order, the catalog's structure and the shape of + /// its cross-reference table — in both output styles — and reopens with nothing to repair. + #[test] + fn full_save_keeps_the_catalog_intact(map in prop::collection::btree_map(0usize..8, value(3), 1..6)) { + let mut doc = Document::new_empty(); + let root = doc.root().expect("an empty document has a catalog"); + doc.update_dict(root, |d| { + for i in INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)) { + d.set(format!("{KEY}{i}").into_bytes(), map[&i].clone()); + } + }) + .map_err(|e| TestCaseError::fail(format!("cannot write the generated catalog: {e}")))?; + // Insertion order is this scrambled sequence filtered to the drawn keys: what must come back. + let expected: Vec<(String, String)> = INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)).map(|i| (format!("{KEY}{i}"), canon(&map[&i]))).collect(); + + // Full key sequence taken from memory BEFORE any byte is written - /Type and /Pages included. + let order_before = catalog_keys(&doc); + + // Right before any save, or the later assertion would prove nothing about saving. + prop_assert_eq!(saved(&doc)?, expected.clone(), "the in-memory catalog is wrong before saving"); + + for object_streams in [true, false] { + let style: &str = if object_streams { "object-stream" } else { "classic-table" }; + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + let bytes = write_full(&doc, &opts).map_err(|e| TestCaseError::fail(format!("full save failed: {e}")))?; + + if !object_streams { + assert_classic_xref_rows(&bytes).map_err(|e| TestCaseError::fail(format!("{style}: {e}")))?; + } + + let again = Document::open(Arc::new(bytes)).map_err(|e| TestCaseError::fail(format!("our own full save must reopen: {e}")))?; + + prop_assert!(again.repair_log().is_empty(), "a file we wrote must not need repair ({}); log: {:?}", style, again.repair_log()); + // Values *and* order: README.md promises dictionaries keep their key order. + prop_assert_eq!(saved(&again)?, expected.clone(), "the catalog changed on a {} save (values or order)", style); + // Structural position too: /Type and /Pages must not drift to the end or swap places. + prop_assert_eq!(catalog_keys(&again), order_before.clone(), "the catalog's full key sequence changed on a {} save", style); + assert_catalog_structure(&again, style)?; + } + } + + /// 2. An incremental save leaves the original bytes untouched, chains back through `/Prev`, and + /// reopens carrying every intended value plus the edit. The base is built **both** ways - + /// cross-reference stream and classic table - because the two are serialised by different code, and a + /// path that never runs cannot fail here. Expected contents are computed from the values we put in, + /// not read back out of the writer: comparing against our own earlier output would pass a writer that + /// is consistently wrong, which is the one thing a fidelity test must not do. + #[test] + fn incremental_save_appends_without_touching_the_original(map in prop::collection::btree_map(0usize..8, value(2), 1..5), marker in -1_000_000i64..1_000_000i64) { + let mut doc = Document::new_empty(); + let root = doc.root().expect("an empty document has a catalog"); + doc.update_dict(root, |d| { + for i in INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)) { + d.set(format!("{KEY}{i}").into_bytes(), map[&i].clone()); + } + }) + .map_err(|e| TestCaseError::fail(format!("cannot write the generated catalog: {e}")))?; + + // Our own record of what the catalog should hold, derived from the inputs before any byte is + // written. The edit key joins it below; order follows INSERTION_ORDER, not sorted order (§7.5.4). + let intended: Vec<(String, String)> = INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)).map(|i| (format!("{KEY}{i}"), canon(&map[&i]))).collect(); + + for object_streams in [true, false] { + let style: &str = if object_streams { "object-stream" } else { "classic-table" }; + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + + let base = write_full(&doc, &opts).map_err(|e| TestCaseError::fail(format!("{}: full save failed: {e}", style)))?; + let base_xref = startxref(&base).ok_or_else(|| TestCaseError::fail(format!("{}: the base file has no readable startxref", style)))?; + + let mut again = Document::open(Arc::new(base.clone())).map_err(|e| TestCaseError::fail(format!("{}: the saved file must reopen: {e}", style)))?; + // Independent of the writer's own readings: the reopen must show what we intended to write. + prop_assert_eq!(saved(&again)?, intended.clone(), "{}: the base catalog differs from what we put in", style); + + let root2 = again.root().expect("the reopened document has a catalog"); + again.update_dict(root2, |d| d.set(b"pxMark".to_vec(), Object::Int(marker))).map_err(|e| TestCaseError::fail(format!("{}: cannot apply the incremental edit: {e}", style)))?; + + let bytes = write_incremental(&again, &opts).map_err(|e| TestCaseError::fail(format!("{}: incremental save failed: {e}", style)))?; + prop_assert!(bytes.len() > base.len(), "{}: an incremental save must add bytes ({} over a base of {})", style, bytes.len(), base.len()); + prop_assert_eq!(bytes.get(..base.len()), Some(&base[..]), "{}: an incremental save must not rewrite the original bytes", style); + + let out = Document::open(Arc::new(bytes.clone())).map_err(|e| TestCaseError::fail(format!("{}: the incremental file must reopen: {e}", style)))?; + prop_assert!(out.repair_log().is_empty(), "{}: an increment we wrote must not need repair; log: {:?}", style, out.repair_log()); + + // The revision chain in the crate's own terms (Revision { xref_offset, is_stream }): two + // revisions, the first still pointing at the base cross-reference. Without that link no other + // reader can reach the earlier revision's objects, which is the classic incremental corruption. + // /Prev sits in the trailer for a table base and inside the cross-reference stream for a + // stream base, so it is not read here; `revisions` covers both (§7.5.3-7.5.6). + let revs = out.revisions(); + prop_assert_eq!(revs.len(), 2, "{}: an incremental save must leave two revisions: {:?}", style, revs); + prop_assert_eq!(revs[0].xref_offset, base_xref, "{}: the first revision must still point at the base xref {}", style, base_xref); + prop_assert!(revs[1].xref_offset > base_xref, "{}: the second revision must sit after the first: {:?}", style, revs); + let ends = out.revision_ends(); + prop_assert_eq!(ends.last().copied(), Some(bytes.len()), "{}: the last revision must run to the end of the file: {:?}", style, ends); + + // The base revision opens on its own. "Nothing was rewritten" buys the user this: cut the file + // at the append point and the earlier document still reads, with our intended values. + if let Some(end0) = ends.first().copied() { + let before_doc = Document::open(Arc::new(bytes[..end0].to_vec())).map_err(|e| TestCaseError::fail(format!("{}: the base revision must open alone: {e}", style)))?; + prop_assert_eq!(saved(&before_doc)?, intended.clone(), "{}: the base revision lost or changed values when opened without the increment", style); + } + + assert_catalog_structure(&out, style)?; + + let mut expected = intended.clone(); + expected.push(("pxMark".into(), canon(&Object::Int(marker)))); + prop_assert_eq!(saved(&out)?, expected.clone(), "{}: the increment lost a value, reordered the catalog, or dropped the edit", style); + } + } + + /// 3. Writes then deletes against a plain ordered model, checked after every step and again after + /// save and reopen. Deletes always target a key that exists, so the delete path is never empty. + #[test] + fn edit_sequence_equals_a_plain_map(plan in prop::collection::btree_map(0usize..8, value(2), 1..6).prop_flat_map(|sets| { + let keys: Vec = sets.keys().copied().collect(); + (Just(sets), prop::collection::vec(0usize..keys.len(), 1..=keys.len())).prop_map(move |(s, picks)| { + let mut dels: Vec = picks.into_iter().map(|i| keys[i]).collect(); + // Deduplicated: the same key drawn twice would make the second delete target a key that is + // already gone, which is legal PDF but not what this property means to assert. `picks` draws + // 1..=keys.len() over a non-empty key set, so `dels` cannot come back empty — the guarantee + // lives in the generator, not in an assume that could never fire. + dels.sort_unstable(); + dels.dedup(); + (s, dels) + }) + })) { + let (sets, deletes) = plan; + let mut doc = Document::new_empty(); + let root = doc.root().expect("an empty document has a catalog"); + + let mut model: Vec<(String, String)> = Vec::new(); + for (i, o) in &sets { + let key = format!("{KEY}{i}"); + doc.update_dict(root, |d| d.set(key.clone().into_bytes(), o.clone())).map_err(|e| TestCaseError::fail(format!("cannot write {key}: {e}")))?; + model.push((key.clone(), canon(o))); + prop_assert_eq!(saved(&doc)?, model.clone(), "after writing {} the catalog is wrong before saving", key); + } + + for i in &deletes { + let key = format!("{KEY}{i}"); + prop_assert!(model.iter().any(|(k, _)| k == &key), "the generator tried to delete a key that was never written: {}", key); + doc.update_dict(root, |d| { + d.remove(key.as_bytes()); + }) + .map_err(|e| TestCaseError::fail(format!("cannot delete {key}: {e}")))?; + model.retain(|(k, _)| k != &key); + prop_assert_eq!(saved(&doc)?, model.clone(), "after deleting {} it is still reachable", key); + } + + let bytes = write_full(&doc, &SaveOptions::default()).map_err(|e| TestCaseError::fail(format!("full save failed: {e}")))?; + let again = Document::open(Arc::new(bytes)).map_err(|e| TestCaseError::fail(format!("our own save must reopen: {e}")))?; + let got = saved(&again)?; + prop_assert_eq!(got.clone(), model.clone(), "the reopened catalog differs from the edit history (values or order)"); + for i in 0..8usize { + let key = format!("{KEY}{i}"); + if !model.iter().any(|(k, _)| k == &key) { + prop_assert!(!got.iter().any(|(g, _)| *g == key), "a deleted key came back after save+reopen: {}", key); + } + } + } +} From a5ef6b76e8ee8dba14361cdddeda03e1ffc39ebc Mon Sep 17 00:00:00 2001 From: FredApps Date: Fri, 16 Oct 2026 02:17:12 +0200 Subject: [PATCH 05/31] Wake idle windows for pending recovery snapshots (#2663) --- crates/engine/src/lib.rs | 5 ++ crates/engine/src/tests.rs | 4 ++ crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/recovery.rs | 93 +++++++++++++++++++++++++++++++++- docs/roadmap.md | 5 ++ 5 files changed, 107 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 01f162ae7..9ded73c3e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4666,6 +4666,11 @@ impl Session { write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) } + /// Whether a changed document still needs a recovery snapshot. This does not consume it. + pub fn has_pending_autosave(&self) -> bool { + self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation) + } + /// Documents with unsaved changes made since the last call: their current working file, /// for crash recovery. Encrypted documents stay encrypted in the snapshot. pub fn autosave_snapshots(&mut self) -> Vec { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed90eea41..7a3fcf726 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1233,9 +1233,13 @@ fn owner_password_of_older_revisions_opens_the_viewer_too() { #[test] fn autosave_snapshots_only_changed_documents() { let (mut s, id) = session_with(2); + assert!(!s.has_pending_autosave()); assert!(s.autosave_snapshots().is_empty(), "clean documents are not snapshotted"); s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + assert!(s.has_pending_autosave()); + assert!(s.has_pending_autosave(), "checking a deadline must not consume a snapshot"); let snaps = s.autosave_snapshots(); + assert!(!s.has_pending_autosave()); assert_eq!(snaps.len(), 1); assert_eq!(snaps[0].bytes, s.get(id).unwrap().bytes, "the working file"); assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d101d812e..00a163a60 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3028,7 +3028,7 @@ impl eframe::App for PdfCraftApp { // macOS: quit through `terminate:` instead of dropping the window (Touch Bar crash, #1136). craft_quit::on_close_request(ctx); let now = ctx.input(|i| i.time); - self.autosave_tick(now); + self.autosave_tick(ctx, now); // Reload documents whose files a build rewrote (#431). #[cfg(not(target_arch = "wasm32"))] self.watch_files(); diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 4f1f1d8e8..a691bcdfe 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -290,7 +290,7 @@ impl PdfCraftApp { } /// Autosave when the interval has passed (called every frame). - pub(crate) fn autosave_tick(&mut self, now: f64) { + pub(crate) fn autosave_tick(&mut self, ctx: &egui::Context, now: f64) { if self.recovery.is_none() { return; } @@ -298,6 +298,14 @@ impl PdfCraftApp { self.last_autosave = now; self.autosave_now(); } + // An idle native window has no input-driven frames. Wake it at the existing + // autosave deadline, without keeping clean or already-snapshotted documents awake. + if self.session.has_pending_autosave() { + let remaining = (self.last_autosave + AUTOSAVE_SECS - now).clamp(0.0, AUTOSAVE_SECS); + if let Ok(delay) = std::time::Duration::try_from_secs_f64(remaining) { + ctx.request_repaint_after(delay); + } + } } /// Reopen recovered documents (`keys`), as unsaved changes at their original paths. @@ -385,3 +393,86 @@ impl PdfCraftApp { } } } + +#[cfg(test)] +mod idle_tests { + use super::*; + use std::time::Duration; + + struct TestDir(PathBuf); + + impl TestDir { + fn new() -> Self { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("pdfcraft-idle-recovery-{}-{nonce}", std::process::id())); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, now: f64) -> Duration { + let mut out = ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| { + app.autosave_tick(ui.ctx(), now); + }); + // Headless timer test: acknowledge texture uploads without a GPU backend. + out.textures_delta.clear(); + out.viewport_output[&egui::ViewportId::ROOT].repaint_delay + } + + #[test] + fn idle_dirty_document_requests_its_deadline_and_writes_without_input() { + let dir = TestDir::new(); + let store = RecoveryStore::new(dir.path()); + let mut app = PdfCraftApp::new(); + app.enable_recovery(store.clone()); + let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); + let id = app.session.open_new("Untitled.pdf", bytes).unwrap(); + let ctx = egui::Context::default(); + let mut delay = Duration::ZERO; + for _ in 0..4 { + delay = frame(&mut app, &ctx, 15.0); + } + assert!(delay > Duration::ZERO && delay <= Duration::from_secs(45), "idle deadline: {delay:?}"); + assert!(store.list().is_empty()); + frame(&mut app, &ctx, 60.0); + assert_eq!(store.list().len(), 1, "timer frame writes the snapshot without input"); + assert!(app.session.get(id).unwrap().dirty, "autosave does not mark the document saved"); + assert!(!app.session.has_pending_autosave()); + for _ in 0..4 { + delay = frame(&mut app, &ctx, 61.0); + } + assert_eq!(delay, Duration::MAX, "unchanged snapshot needs no periodic wake"); + } + + #[test] + fn clean_documents_and_disabled_recovery_do_not_schedule_autosave() { + for recovery_enabled in [false, true] { + let dir = TestDir::new(); + let mut app = PdfCraftApp::new(); + if recovery_enabled { + app.enable_recovery(RecoveryStore::new(dir.path())); + } + let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); + if recovery_enabled { + app.session.open("Clean.pdf", None, bytes, None).unwrap(); + } else { + app.session.open_new("Unsaved.pdf", bytes).unwrap(); + } + let ctx = egui::Context::default(); + let mut delay = Duration::ZERO; + for _ in 0..4 { + delay = frame(&mut app, &ctx, 15.0); + } + assert_eq!(delay, Duration::MAX); + } + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 3148823ea..85b476444 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -41,6 +41,11 @@ live in `plan/execution-plan.md` (local-only, gitignored). Keep it current: update the milestone row and the [ROADMAP.md](../ROADMAP.md) progress log at the end of every session; re-estimate when a milestone lands. +Recovery scheduling (#2663): a native window with a pending dirty snapshot requests a +repaint at the existing 60-second autosave deadline, even with no mouse or keyboard input. +Clean and already-snapshotted documents do not schedule recovery wakeups. Snapshot contents, +encryption and storage-write retry behavior are unchanged; this does not resolve #1803. + ## Alpha gate The stage standard's gate: Acrobat Pro's everyday core workflows must each work end to end on the From 10b768ff42c4b25dcb1992f5a37c368f81910a76 Mon Sep 17 00:00:00 2001 From: FredApps Date: Fri, 16 Oct 2026 08:08:35 +0200 Subject: [PATCH 06/31] Test idle autosave after a second edit --- crates/ui-egui/src/recovery.rs | 47 ++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index a691bcdfe..b0b3761f5 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -453,6 +453,53 @@ mod idle_tests { assert_eq!(delay, Duration::MAX, "unchanged snapshot needs no periodic wake"); } + #[test] + fn second_edit_after_autosave_schedules_next_deadline_and_updates_snapshot() { + let dir = TestDir::new(); + let store = RecoveryStore::new(dir.path()); + let mut app = PdfCraftApp::new(); + app.enable_recovery(store.clone()); + let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); + let id = app.session.open_new("Untitled.pdf", bytes).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, 60.0); + let entries = store.list(); + assert_eq!(entries.len(), 1); + let key = entries[0].key.clone(); + let first = store.read(&key).unwrap(); + assert!(!app.session.has_pending_autosave()); + let mut delay = Duration::ZERO; + for _ in 0..4 { + delay = frame(&mut app, &ctx, 61.0); + } + assert_eq!(delay, Duration::MAX); + + app.session.apply(id, pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Second edit".into() }).unwrap(); + assert!(app.session.has_pending_autosave()); + for _ in 0..4 { + delay = frame(&mut app, &ctx, 75.0); + } + assert!(delay > Duration::ZERO && delay <= Duration::from_secs(45), "second edit wakes by 120 seconds: {delay:?}"); + assert_eq!(store.read(&key).unwrap(), first, "editing does not overwrite the snapshot before its deadline"); + frame(&mut app, &ctx, 119.0); + assert_eq!(store.read(&key).unwrap(), first); + frame(&mut app, &ctx, 120.0); + let entries = store.list(); + assert_eq!(entries.len(), 1, "the same document replaces its existing snapshot"); + assert_eq!(entries[0].key, key); + let second = store.read(&key).unwrap(); + assert_ne!(second, first); + let mut recovered = pdfcraft_engine::Session::new(); + let restored = recovered.open("Snapshot.pdf", None, second.into(), None).unwrap(); + assert_eq!(recovered.get(restored).unwrap().info_value("Title"), Some("Second edit".into())); + assert!(app.session.get(id).unwrap().dirty); + assert!(!app.session.has_pending_autosave()); + for _ in 0..4 { + delay = frame(&mut app, &ctx, 121.0); + } + assert_eq!(delay, Duration::MAX, "the second snapshot also lets the window sleep"); + } + #[test] fn clean_documents_and_disabled_recovery_do_not_schedule_autosave() { for recovery_enabled in [false, true] { From 26051871d0772b4befa031f24722320a867d825d Mon Sep 17 00:00:00 2001 From: Enrico Sita Date: Tue, 13 Oct 2026 12:12:01 +0200 Subject: [PATCH 07/31] M11: remove PDF/A or PDF/X identification (optimize.remove-standards-id) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file edited after it was made PDF/A or PDF/X can keep claiming a standard it no longer meets. organize::remove_standard_id removes the claim: the pdfaid: XMP properties for PDF/A, or GTS_PDFXVersion and GTS_PDFXConformance in the document information and as pdfxid: XMP properties for PDF/X. Output intents and other claims (PDF/UA) stay. The XMP is edited with the packet editor Document Properties already uses (sync_info and the new remove_properties share its read and write), so the rest of the packet stays byte for byte and unsafe packets are left alone. It is one undoable edit, in Standards ▸ PDF/A as a button worded like its undo label, and the standards_remove_id tool (pdfa or pdfx). Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.toml | 6 ++--- crates/automation/src/lib.rs | 8 ++++++ crates/automation/src/tools.rs | 3 +++ crates/automation/tests/automation.rs | 4 +++ crates/engine/src/lib.rs | 9 +++++++ crates/organize/src/lib.rs | 35 +++++++++++++++++++++++++++ crates/organize/src/tests.rs | 31 ++++++++++++++++++++++++ crates/organize/src/xmp.rs | 35 +++++++++++++++++++++------ crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/lt.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/standards_ui.rs | 11 ++++++++- crates/ui-egui/tests/compare.rs | 5 ++++ parity/acrobat-features.toml | 10 ++++---- 14 files changed, 144 insertions(+), 16 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index be9e2c448..c084b98ca 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2acb3f6522d6d1f744343f474fe4c51ebec610a888a7fa83fc9422228e6fa3c1" +sha256 = "861492683ca8d30ee241384d1ddbe2209b9d5dcfd661dae7b184b3dd601b2935" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "17c8579e0e9d1524ffbeb87e7b30c14562dbaac64400826ee786cecfe31648fa" +sha256 = "f3931df85fd67f791fcceaf5eaf5bf3d0d7d0f2d5e0e0ec089a6dd6887b51a69" [[asset]] @@ -3352,7 +3352,7 @@ 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 = "2d1c4b4276d02512a4e20101be95a1671de5c4f5cdfd661887af5ebab0db06a6" +sha256 = "1d81f9e76210e32df89d8f0af88dd802ef89f10d453ba94e077856a3064f4247" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 88856305b..3dc2cc8dc 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -782,6 +782,14 @@ impl Automation { "print_readiness" => self.print_readiness(&a)?, "doc_export_office" => self.doc_export_office(&a)?, "pdfa_convert" => self.pdfa_convert(&a)?, + "standards_remove_id" => { + let standard = match a.str("standard")? { + "pdfa" => pdfcraft_engine::Standard::PdfA, + "pdfx" => pdfcraft_engine::Standard::PdfX, + other => return Err(ToolError::InvalidArgs(format!("standard must be pdfa or pdfx, not {other}"))), + }; + self.apply(&a, Edit::RemoveStandardId { standard })? + } "action_run" => self.action_run(&a)?, "doc_compare_report" => self.doc_compare_report(&a)?, "doc_compare_mark" => self.doc_compare_mark(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 5e89aef78..ad3ded203 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1073,6 +1073,9 @@ pub fn tools() -> Vec { t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])), + t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.") + .cmd("standards.pdfa") + .with(schema(json!({ "doc": doc(), "standard": { "type": "string", "enum": ["pdfa", "pdfx"] } }), &["doc", "standard"])), t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).") .ro() .cmd("actions.wizard") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f3..9dcbd3030 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5038,6 +5038,10 @@ fn pdfa_through_tools() { assert_eq!(r["declared"]["pdfa"], "PDF/A-3b", "{r}"); assert!(r["issues"].as_array().unwrap().iter().all(|i| i["fixable"] == false), "{r}"); assert!(a.call("pdfa_verify", &json!({ "doc": doc, "level": "9z" })).is_err()); + ok(&mut a, "standards_remove_id", json!({ "doc": doc, "standard": "pdfa" })); + let r = ok(&mut a, "pdfa_verify", json!({ "doc": doc })); + assert!(r["declared"]["pdfa"].is_null() && !r["declared"]["output_intents"].as_array().unwrap().is_empty(), "{r}"); + assert!(matches!(a.call("standards_remove_id", &json!({ "doc": doc, "standard": "pdfa" })), Err(ToolError::Failed(_))), "nothing left"); } #[test] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3432cd868..5052b7321 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -48,6 +48,7 @@ pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transitio /// One file produced by a split: (1-based first page, last page, PDF bytes). pub use pdfcraft_organize::LabelStyle; +pub use pdfcraft_organize::Standard; pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; pub use pdfcraft_cos::Algorithm; @@ -1529,6 +1530,10 @@ pub enum Edit { ConvertPdfA { level: pdfcraft_preflight::Level, }, + /// Remove what identifies the document as meeting `standard` (PDF/A or PDF/X). + RemoveStandardId { + standard: Standard, + }, /// What a script (button or console) changed in form fields: values, read-only, required /// and visibility. ApplyScriptChanges { @@ -1860,6 +1865,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Run JavaScript", "Run form script", "Save as {level}", + "Remove {standard} identification", "Edit script of {name}", "Delete document JavaScript", "Edit document JavaScript", @@ -1991,6 +1997,7 @@ impl Edit { Edit::XfaEvent { .. } => Label::of("Run form script"), // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), + Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()), Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), @@ -2186,6 +2193,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetDocumentScript { .. } | Edit::SetFieldScript { .. } | Edit::ConvertPdfA { .. } + | Edit::RemoveStandardId { .. } | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } | Edit::RegisterTextFont { .. } @@ -2488,6 +2496,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::ConvertPdfA { level } => { pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; } + Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?, Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, Edit::AddOcrText { page, words } => { pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 3b2873abe..3cae4b3e2 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -579,5 +579,40 @@ fn set_info_entry(doc: &mut Document, key: &[u8], entry: Option) -> Resu Ok(()) } +/// A standard a document can declare it meets, for [`remove_standard_id`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Standard { + PdfA, + PdfX, +} + +impl Standard { + pub fn label(self) -> &'static str { + match self { + Standard::PdfA => "PDF/A", + Standard::PdfX => "PDF/X", + } + } +} + +/// Remove what identifies the document as meeting `standard`: the `pdfaid:` XMP properties of +/// ISO 19005, or ISO 15930's `GTS_PDFXVersion` and `GTS_PDFXConformance` in the document +/// information and as `pdfxid:` XMP properties. Output intents and other metadata stay. Refused +/// when the document declares nothing of the kind. +pub fn remove_standard_id(doc: &mut Document, standard: Standard) -> Result<(), OrganizeError> { + let (props, info_keys): (&[&str], &[&str]) = match standard { + Standard::PdfA => (&["pdfaid:part", "pdfaid:conformance", "pdfaid:amd", "pdfaid:rev"], &[]), + Standard::PdfX => (&["pdfxid:GTS_PDFXVersion", "pdfxid:GTS_PDFXConformance"], &["GTS_PDFXVersion", "GTS_PDFXConformance"]), + }; + let mut removed = xmp::remove_properties(doc, props)?; + for key in info_keys { + if info(doc, key).is_some() { + set_info_entry(doc, key.as_bytes(), None)?; + removed = true; + } + } + if removed { Ok(()) } else { Err(OrganizeError::Invalid(format!("the document doesn't declare {}", standard.label()))) } +} + #[cfg(test)] mod tests; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index d34b1e431..fdc8739f8 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -928,6 +928,37 @@ fn malformed_output_intents_and_identification_do_not_fail() { assert!(!xmp.contains('\0') && !xmp.contains("GTS_PDFXConformance"), "{xmp}"); } +#[test] +fn removing_the_pdfx_identification_keeps_the_output_intent_and_other_claims() { + let mut x4 = doc_x4("FOGRA51", "icc"); + assert!(matches!(remove_standard_id(&mut x4, Standard::PdfA), Err(OrganizeError::Invalid(_)))); + assert!(!x4.is_modified(), "a refused removal changes nothing"); + remove_standard_id(&mut x4, Standard::PdfX).unwrap(); + let x = pdfx_of(&full_roundtrip(&x4)); + assert_eq!((x.version, x.intents), (None, vec![("FOGRA51".to_string(), b"icc".to_vec())])); + assert!(x.xmp.is_some_and(|x| !x.contains("pdfxid:GTS") && x.contains("pdfuaid:part=\"1\"")), "PDF/UA's claim stays"); + assert!(matches!(remove_standard_id(&mut x4, Standard::PdfX), Err(OrganizeError::Invalid(_))), "nothing left to remove"); + + let mut x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001) /GTS_PDFXConformance (PDF/X-1a:2001)", None); + remove_standard_id(&mut x1a, Standard::PdfX).unwrap(); + assert_eq!((info(&x1a, "GTS_PDFXVersion"), info(&x1a, "GTS_PDFXConformance")), (None, None)); +} + +#[test] +fn removing_the_pdfa_identification_removes_its_xmp_properties_in_either_form() { + let xmp = "\ + 2Report\ + "; + let mut doc = doc_print("sRGB IEC61966-2.1", "icc", "/Title (Report)", Some(xmp)); + remove_standard_id(&mut doc, Standard::PdfA).unwrap(); + let x = pdfx_of(&full_roundtrip(&doc)); + let packet = x.xmp.unwrap(); + assert!(!packet.contains("pdfaid:part") && !packet.contains("pdfaid:conformance"), "{packet}"); + assert!(packet.contains(">Report"), "the rest of the packet stays: {packet}"); + assert_eq!(x.intents.len(), 1, "the output intent stays"); +} + #[test] fn duplicate_pages_become_independent_copies() { let out = full_roundtrip(&extract_pages(&doc_b(), &[0, 0]).unwrap()); diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs index 3efa8d265..bbcbbcf83 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -54,21 +54,42 @@ fn property(key: &str) -> Option<(&'static str, &'static str, Shape)> { /// that can't be edited safely. pub(crate) fn sync_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { let Some((name, ns, shape)) = property(key) else { return Ok(()) }; - let Some(root) = doc.root() else { return Ok(()) }; + edit_packet(doc, |xmp| set_property(xmp, name, ns, shape, value)).map(|_| ()) +} + +/// Remove every occurrence of the properties `names` from the catalog's XMP packet; whether any +/// was there. +pub(crate) fn remove_properties(doc: &mut Document, names: &[&str]) -> Result { + edit_packet(doc, |xmp| { + let mut out: Option = None; + for name in names { + // Each removal shortens the packet, so this ends. + while let Some(next) = set_property(out.as_deref().unwrap_or(xmp), name, "", Shape::Text, "") { + out = Some(next); + } + } + out + }) +} + +/// Rewrite the catalog's XMP packet with `edit` (`None`: unchanged); whether it changed. Files +/// without XMP and packets that can't be edited safely are left alone. +fn edit_packet(doc: &mut Document, edit: impl FnOnce(&str) -> Option) -> Result { + let Some(root) = doc.root() else { return Ok(false) }; // A metadata stream is always indirect (§7.3.8); anything else is left alone. - let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"Metadata")) else { return Ok(()) }; + let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"Metadata")) else { return Ok(false) }; let obj = doc.get(r); - let Object::Stream(stream) = &*obj else { return Ok(()) }; - let Ok(bytes) = stream.decoded_within(MAX_XMP) else { return Ok(()) }; - let Ok(xmp) = String::from_utf8(bytes) else { return Ok(()) }; - let Some(updated) = set_property(&xmp, name, ns, shape, value) else { return Ok(()) }; + let Object::Stream(stream) = &*obj else { return Ok(false) }; + let Ok(bytes) = stream.decoded_within(MAX_XMP) else { return Ok(false) }; + let Ok(xmp) = String::from_utf8(bytes) else { return Ok(false) }; + let Some(updated) = edit(&xmp) else { return Ok(false) }; // Written unfiltered, as PDF/A asks of metadata streams, so other tools can read it. let mut dict = stream.dict.clone(); for k in [&b"Filter"[..], b"DecodeParms", b"DL", b"Length"] { dict.remove(k); } doc.set(r, Object::Stream(Stream::from_raw(dict, updated.into_bytes()))); - Ok(()) + Ok(true) } /// `xmp` with the property `name` (namespace `ns`) set to `value`, or removed when `value` is diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e4eb63446..52b52c1ea 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2362,6 +2362,7 @@ action wizard Start Iniciar Change Subject Cambiar el asunto Change Keywords Cambiar las palabras clave Save as {level} Guardar como {level} + Remove {standard} identification Quitar la identificación {standard} Create links Crear enlaces # Multi-object selection and movement (#844) diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 56a785cd3..851021303 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2680,6 +2680,7 @@ key Space Tarpas # AI-assisted additions for concurrently merged UI features (2026-10-15). # These additions have not had separate native-speaker review. Mouse wheel scroll speed Pelės ratuko slinkimo greitis + Remove {standard} identification Pašalinti {standard} identifikaciją The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti. theme Appearance Išvaizda diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b07275523..fac14f360 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2658,6 +2658,7 @@ key Space Space Redaction mark appearance Hình thức dấu xóa nội dung nhạy cảm Remove Crop Bỏ xén Remove this leaf tag from reading order and mark its direct content as an artifact Xóa thẻ lá này khỏi thứ tự đọc và đánh dấu nội dung trực tiếp của nó là thành phần trang trí + Remove {standard} identification Gỡ thông tin nhận dạng {standard} Resolution: Độ phân giải: Save anyway Vẫn lưu Save over the changed file? Lưu đè lên tệp đã thay đổi? diff --git a/crates/ui-egui/src/standards_ui.rs b/crates/ui-egui/src/standards_ui.rs index 1d409f200..5fc4b3d35 100644 --- a/crates/ui-egui/src/standards_ui.rs +++ b/crates/ui-egui/src/standards_ui.rs @@ -53,7 +53,16 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool let mut standards: Vec = declared.pdfa.iter().map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())).collect(); standards.extend(declared.pdfua.map(|p| format!("PDF/UA-{p}"))); let shown = if standards.is_empty() { tl!("none").to_string() } else { standards.join(", ") }; - ui.label(crate::i18n::fmt(tl!("Declared conformance: {shown}"), &[("shown", &shown)])); + let removal = pdfcraft_engine::Edit::RemoveStandardId { standard: pdfcraft_engine::Standard::PdfA }; + let mut remove = false; + ui.horizontal(|ui| { + ui.label(crate::i18n::fmt(tl!("Declared conformance: {shown}"), &[("shown", &shown)])); + // Worded as Edit ▸ Undo will name it. + remove = declared.pdfa.is_some() && widgets::pill_button(ui, &crate::i18n::edit_label(&removal.label()), false).clicked(); + }); + if remove && app.apply_edit(removal) { + app.pdfa.issues = None; + } if !declared.output_intents.is_empty() { ui.label( egui::RichText::new(crate::i18n::fmt(tl!("Output intent: {list}"), &[("list", &declared.output_intents.join(", "))])).color(t.text_muted), diff --git a/crates/ui-egui/tests/compare.rs b/crates/ui-egui/tests/compare.rs index e735668a1..13b6f0d47 100644 --- a/crates/ui-egui/tests/compare.rs +++ b/crates/ui-egui/tests/compare.rs @@ -96,6 +96,7 @@ fn pdfa_dialog_verifies_and_converts() { assert!(h.state_mut().execute("standards.pdfa")); h.run_steps(2); h.get_by_label("Declared conformance: none"); + assert!(h.query_by_label("Remove PDF/A identification").is_none(), "nothing to remove yet"); h.get_by_label("Verify").click(); h.run_steps(3); h.get_by_label("The document has no XMP metadata"); @@ -106,6 +107,10 @@ fn pdfa_dialog_verifies_and_converts() { assert!(issues.iter().all(|i| !i.fixable), "{issues:?}"); let id = h.state().active_ids().unwrap().1; assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Save as PDF/A-2b")); + h.get_by_label("Remove PDF/A identification").click(); + h.run_steps(3); + h.get_by_label("Declared conformance: none"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Remove PDF/A identification")); } /// A one-page PDF whose XMP declares the given `pdfaid`/`pdfuaid` attributes. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94..56d5a785a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7893,11 +7893,11 @@ title = "Remove PDF/A or PDF/X identification" area = "K" tier = "P2" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +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"] +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]] id = "optimize.pdfx5-reference-xobjects" From 06c4a970a242418e8147e7499417a586c0fd052c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?enes=20y=C4=B1ld=C4=B1z?= Date: Fri, 16 Oct 2026 08:11:00 +0000 Subject: [PATCH 08/31] =?UTF-8?q?fix(cli):=20info,=20text,=20render=20and?= =?UTF-8?q?=20audit=20take=20the=20owner=20password=20of=20R2=E2=80=93R4?= =?UTF-8?q?=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pdfcraft-cli info`, `text`, `render` and `audit` rejected the owner password of RC4-40, RC4-128 and AES-128 files with "the password is incorrect", while `run doc_open` accepted it (#2670, item 8). The renderer authenticates only the user password of revisions 2–4. `Session::open` works around that by handing it the user password that owner authentication recovers, but the CLI passed the typed password straight to `inspect` and `PageRenderer`. `pdfcraft_engine::render_password_for` now gives any render path outside a session the password the renderer takes, sharing the recovery with `Session::open`. The CLI uses it for the renderer and keeps the typed password for the engine session and the cos document, so the owner password still lifts copy restrictions in `text`. The render test decodes the PNG and checks its dimensions and that it has non-white pixels, for every algorithm with either password. Refs #2670 (item 8 only). Checks: cargo fmt --check; cargo clippy -p pdfcraft-cli -p pdfcraft-engine --all-targets -- -D warnings; cargo test -p pdfcraft-cli; cargo test -p pdfcraft-engine (and --lib); cargo xtask layers, assets, parity. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/pdfcraft-cli/Cargo.toml | 4 + apps/pdfcraft-cli/src/main.rs | 24 +++- apps/pdfcraft-cli/tests/owner_password.rs | 154 ++++++++++++++++++++++ crates/engine/src/lib.rs | 22 +++- crates/engine/src/tests.rs | 26 ++++ parity/acrobat-features.toml | 4 +- 7 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 apps/pdfcraft-cli/tests/owner_password.rs diff --git a/Cargo.lock b/Cargo.lock index 91ed3c490..9f923a32a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4022,6 +4022,7 @@ dependencies = [ "pdfcraft-engine", "pdfcraft-platform", "pdfcraft-render", + "png 0.18.1", "rustix 1.1.5", "serde_json", "winresource", diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index d7b52fbd6..cf960a9a9 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -22,6 +22,10 @@ pdfcraft-automation = { workspace = true } pdfcraft-platform = { workspace = true } base64 = "0.22" +[dev-dependencies] +# Decodes `render` output in tests/owner_password.rs; already a dependency of the engine. +png = "0.18" + [target.'cfg(unix)'.dependencies] # `geteuid` for `ui`'s control-file owner check: safe, so no `unsafe` libc call here. # Already in Cargo.lock through the GUI crates. diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 0e24800d3..69c4371c0 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -175,7 +175,12 @@ fn audit(args: &[String]) -> Result<(), CliError> { let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?; // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions` // reports them; the page geometry is read only when there is something to place. - let info = if findings.is_empty() { None } else { Some(inspect(bytes, password).map_err(|e| format!("{path}: {e}"))?) }; + let info = if findings.is_empty() { + None + } else { + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?) + }; let items: Vec = findings .into_iter() .map(|f| { @@ -298,7 +303,9 @@ fn info(args: &[String]) -> Result<(), CliError> { let path = *positional(args).first().ok_or("info: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); - let info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; + // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; // Dynamic XFA forms: say what laying the template out gives (what the app shows). let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) .then(|| { @@ -349,12 +356,14 @@ fn text(args: &[String]) -> Result<(), CliError> { } let password = flag(args, "--password"); let bytes = read(path)?; - let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() }); + // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }); // A document the renderer could not open (wrong or missing password, unparseable file) has // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask // `inspect` why instead, so a protected file fails the same way `info` does. if r.page_count() == 0 { - let info = inspect(bytes.clone(), password).map_err(|e| format!("text: {e}"))?; + let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?; if !info.pages.is_empty() { return Err("text: the document could not be parsed".into()); } @@ -588,9 +597,12 @@ fn render(args: &[String]) -> Result<(), CliError> { // The app renders a display copy that draws comments missing their /AP streams; the CLI // must agree with it (and with independent viewers), while keeping the file it reads intact. let bytes = read(path)?; + let password = flag(args, "--password"); + // The renderer takes only the user password of R2–R4 files; the display copy takes either. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); let mut r = PageRenderer::new( - pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")), - RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() }, + pdfcraft_engine::display_bytes_for(bytes.clone(), password), + RenderConfig { password: render_password.as_deref().map(Arc::from), reject_oversize: true, ..Default::default() }, ); let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); if let Some(e) = p.error { diff --git a/apps/pdfcraft-cli/tests/owner_password.rs b/apps/pdfcraft-cli/tests/owner_password.rs new file mode 100644 index 000000000..3542a7d4c --- /dev/null +++ b/apps/pdfcraft-cli/tests/owner_password.rs @@ -0,0 +1,154 @@ +//! `info`, `text` and `render` take the owner password of revision 2–4 files, as `run doc_open` +//! does (#2670, item 8). The renderer authenticates only user passwords of those revisions, so +//! the CLI must hand it the user password that owner authentication recovers. + +use std::process::Command; +use std::sync::Arc; + +use pdfcraft_cos::{Algorithm, NewEncryption}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// A one-page 200×300 pt PDF reading "Page 1", drawn by `content`. +fn fixture(content: &str) -> Vec { + let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()); + let objs: [&[u8]; 5] = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>", + stream.as_bytes(), + ]; + 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", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +const PAGE: &str = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET"; + +/// `fixture` protected with user password "user" and owner password "owner", allowing nothing, +/// so copying its text needs the owner password. +fn protected(algorithm: Algorithm) -> Vec { + protected_with(algorithm, PAGE) +} + +fn protected_with(algorithm: Algorithm, content: &str) -> Vec { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(content))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm, + user_password: "user", + owner_password: "owner", + permissions: 0, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() +} + +fn tmp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("pdfcraft-cli-owner-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name) +} + +const ALGORITHMS: [Algorithm; 4] = [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256]; + +#[test] +fn info_accepts_the_owner_password() { + for alg in ALGORITHMS { + let path = tmp(&format!("info-{alg:?}.pdf")); + std::fs::write(&path, protected(alg)).unwrap(); + for pw in ["owner", "user"] { + let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", pw]).output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}"); + let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(json["pages"], 1, "{alg:?} with {pw:?}"); + assert_eq!(json["encrypted"], true, "{alg:?} with {pw:?}"); + } + let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", "wrong"]).output().unwrap(); + assert!(!out.status.success(), "{alg:?}: a wrong password still fails"); + assert!(String::from_utf8_lossy(&out.stderr).contains("incorrect"), "{alg:?}"); + } +} + +#[test] +fn text_accepts_the_owner_password() { + for alg in ALGORITHMS { + let path = tmp(&format!("text-{alg:?}.pdf")); + std::fs::write(&path, protected(alg)).unwrap(); + let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "owner"]).output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{alg:?}: {stderr}"); + assert!(String::from_utf8_lossy(&out.stdout).contains("Page 1"), "{alg:?}"); + // The user password opens it, but the permissions still forbid copying. + let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "user"]).output().unwrap(); + assert!(!out.status.success(), "{alg:?}: copying needs the owner password"); + assert!(String::from_utf8_lossy(&out.stderr).contains("don't allow copying"), "{alg:?}"); + } +} + +/// Asserts that `data` is a PNG of the fixture's 200×300 pt page at `dpi` with the text drawn: +/// a blank page of the right size fails too. +fn assert_rendered_page(data: &[u8], dpi: u32, what: &str) { + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap_or_else(|e| panic!("{what}: {e}")); + let mut buf = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut buf).unwrap(); + assert_eq!((frame.width, frame.height), (200 * dpi / 72, 300 * dpi / 72), "{what}"); + // `render` writes 8-bit RGBA (`pdfcraft_engine::export::encode_png`). + assert_eq!((frame.color_type, frame.bit_depth), (png::ColorType::Rgba, png::BitDepth::Eight), "{what}"); + // Opaque pixels with every colour channel below half intensity: ink, not white paper. + let inked = buf[..frame.buffer_size()].as_chunks::<4>().0.iter().filter(|[r, g, b, a]| [r, g, b].iter().all(|c| **c < 128) && *a > 128).count(); + assert!(inked > 100, "{what}: only {inked} inked pixels, the page is blank"); +} + +#[test] +fn render_accepts_the_owner_password() { + const DPI: u32 = 144; + for alg in ALGORITHMS { + let path = tmp(&format!("render-{alg:?}.pdf")); + std::fs::write(&path, protected(alg)).unwrap(); + for pw in ["owner", "user"] { + let png = tmp(&format!("render-{alg:?}-{pw}.png")); + let _ = std::fs::remove_file(&png); + let out = Command::new(BIN) + .args(["render", path.to_str().unwrap(), "--page", "1", "--dpi", &DPI.to_string()]) + .args(["--password", pw, "--out", png.to_str().unwrap()]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}"); + assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}")); + } + } +} + +#[test] +fn audit_places_findings_with_the_owner_password() { + // A black box over the text: a faux redaction, so `audit` reads the page geometry too. + let covered = format!("{PAGE} 0 g 10 140 120 40 re f"); + for alg in ALGORITHMS { + let path = tmp(&format!("audit-{alg:?}.pdf")); + std::fs::write(&path, protected_with(alg, &covered)).unwrap(); + let out = Command::new(BIN).args(["audit", path.to_str().unwrap(), "--password", "owner"]).output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!stderr.contains("incorrect"), "{alg:?}: {stderr}"); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("{alg:?}: {e}: {stderr}")); + assert_eq!(report["findings"][0]["covered_text"], "Page 1", "{alg:?}"); + // Top-left points on the 300 pt high page: the box spans y 140–180 from the bottom. + assert_eq!(report["findings"][0]["rect"], serde_json::json!([10.0, 120.0, 130.0, 160.0]), "{alg:?}"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3432cd868..323238e44 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -912,6 +912,24 @@ pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc>, password: Option<&str>) -> Option { + let password = password?; + let recovered = + guard(|| pdfcraft_cos::Document::open_with_password(bytes, Some(password)).ok().and_then(|d| recovered_render_password(&d))).ok().flatten(); + Some(recovered.unwrap_or_else(|| password.to_owned())) +} + +/// The user password owner authentication recovered from an R2–R4 `cos`, as the renderer takes it. +fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { + cos.security().and_then(|s| s.recovered_user_password()).map(|user| renderer_password(&user)) +} + /// Fails when the renderer still draws text under an applied redaction mark. /// /// Redaction decides what lies under a mark from the positions its own interpreter computes, and @@ -3343,10 +3361,10 @@ impl Session { Ok(info) => (info, password.map(str::to_owned)), Err(OpenError::WrongPassword) => { let user = match &cos { - Ok(Ok(d)) => d.security().and_then(|s| s.recovered_user_password()), + Ok(Ok(d)) => recovered_render_password(d), _ => None, }; - let user = renderer_password(&user.ok_or(OpenError::WrongPassword)?); + let user = user.ok_or(OpenError::WrongPassword)?; (inspect(bytes.clone(), Some(&user))?, Some(user)) } Err(e) => return Err(e), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ec305b1ca..0e0584bab 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1230,6 +1230,32 @@ fn owner_password_of_older_revisions_opens_the_viewer_too() { } } +/// Render paths outside a session (the CLI's `info`, `text`, `render`) get the password the +/// renderer takes: the recovered user password for an R2–R4 owner password, else as given. +#[test] +fn render_password_for_recovers_the_user_password_of_older_revisions() { + for alg in [pdfcraft_cos::Algorithm::Rc4_40, pdfcraft_cos::Algorithm::Rc4_128, pdfcraft_cos::Algorithm::Aes128, pdfcraft_cos::Algorithm::Aes256] { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: alg, + user_password: "u", + owner_password: "o", + permissions: 0, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let owner = crate::render_password_for(bytes.clone(), Some("o")); + let expected = if alg == pdfcraft_cos::Algorithm::Aes256 { "o" } else { "u" }; + assert_eq!(owner.as_deref(), Some(expected), "{alg:?}"); + assert!(pdfcraft_render::inspect(bytes.clone(), owner.as_deref()).is_ok(), "{alg:?}: the renderer opens it"); + assert_eq!(crate::render_password_for(bytes.clone(), Some("u")).as_deref(), Some("u"), "{alg:?}"); + assert_eq!(crate::render_password_for(bytes.clone(), Some("wrong")).as_deref(), Some("wrong"), "{alg:?}"); + assert_eq!(crate::render_password_for(bytes, None), None, "{alg:?}"); + } +} + #[test] fn autosave_snapshots_only_changed_documents() { let (mut s, id) = session_with(2); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94..97d57d4fc 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -202,8 +202,8 @@ 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/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"] -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. Not yet: an automation tool to unlock an open document (reopen with doc_open and the password instead)" +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"] +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]] id = "core.permissions-enforced" From 725828438c8a20cc277df63459ce64d9f402c3cc Mon Sep 17 00:00:00 2001 From: Bode512 Date: Fri, 16 Oct 2026 23:21:00 +0200 Subject: [PATCH 09/31] fix(cli): bad option values name the option and the value, before any file is read --- apps/pdfcraft-cli/src/main.rs | 71 +++++++++++++++++++++++++------ apps/pdfcraft-cli/tests/check.rs | 20 +++++++++ apps/pdfcraft-cli/tests/edit.rs | 33 ++++++++++++-- apps/pdfcraft-cli/tests/render.rs | 24 +++++++++++ apps/pdfcraft-cli/tests/split.rs | 20 +++++++++ 5 files changed, 153 insertions(+), 15 deletions(-) diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 0e24800d3..ed7d5f8d1 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -458,17 +458,31 @@ fn edit(args: &[String]) -> Result<(), CliError> { "--password" => password = Some(value), "--rotate" => { let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?; - edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees: deg.parse().map_err(|_| "bad degrees")? }); + let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?; + // The same rule `rotate_pages` enforces, checked here so a bad angle is named + // with its option before any file is opened. + if degrees % 90 != 0 { + return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into()); + } + edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees }); } "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }), "--move" => { let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?; - let to: usize = to.parse().map_err(|_| "bad target")?; - edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.checked_sub(1).ok_or("--move target must be at least 1")? }); + let target: usize = to + .parse() + .ok() + .filter(|target| *target > 0) + .ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?; + edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 }); } "--insert-blank" => { - let at: usize = value.parse().map_err(|_| "bad position")?; - edits.push(Edit::InsertBlankPage { at: at.checked_sub(1).ok_or("--insert-blank must be at least 1")?, width: 612.0, height: 792.0 }); + let position: usize = value + .parse() + .ok() + .filter(|position| *position > 0) + .ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?; + edits.push(Edit::InsertBlankPage { at: position - 1, width: 612.0, height: 792.0 }); } "--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }), "--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }), @@ -550,7 +564,16 @@ fn split(args: &[String]) -> Result<(), CliError> { use pdfcraft_engine::SplitBy; let path = *positional(args).first().ok_or("split: missing file")?; let by = match (flag(args, "--every"), flag(args, "--before")) { - (Some(n), None) => SplitBy::PageCount(n.parse().map_err(|_| "bad --every")?), + (Some(n), None) => { + // A count the splitter cannot hold is refused here, before the file is opened, + // rather than silently clamped to 1 further down. + let count = n + .parse::() + .ok() + .filter(|count| *count > 0) + .ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?; + SplitBy::PageCount(count) + } (None, Some(list)) => SplitBy::Before(page_list(list)?), _ => return Err("split: give either --every N or --before PAGES".into()), }; @@ -570,12 +593,25 @@ fn split(args: &[String]) -> Result<(), CliError> { Ok(()) } +/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value +/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own +/// internal message (and in `check`, passed on as text, they would turn every file into a +/// child that printed nothing), so the CLI names the option and the value instead. +fn parse_dpi(value: &str) -> Result { + value + .parse::() + .ok() + .filter(|dpi| dpi.is_finite() && *dpi > 0.0) + .ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}")) +} + fn render(args: &[String]) -> Result<(), CliError> { validate_options("render", args, RENDER_OPTIONS)?; let path = *positional(args).first().ok_or("render: missing file")?; - let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?; - let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?; - let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?; + let page: usize = + flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?; + let page_index = page - 1; + let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?; let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?; // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream. let format = match Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { @@ -621,7 +657,7 @@ fn render(args: &[String]) -> Result<(), CliError> { fn check_one(args: &[String]) -> Result<(), CliError> { validate_options("check-one", args, CHECK_ONE_OPTIONS)?; let path = *positional(args).first().ok_or("check-one: missing file")?; - let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?; + let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?; let start = Instant::now(); let bytes = read(path)?; let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { @@ -713,12 +749,23 @@ fn collect(paths: &[&str]) -> Vec { fn check(args: &[String]) -> Result<(), CliError> { validate_options("check", args, CHECK_OPTIONS)?; + // Every option is checked before the inputs are scanned, so a bad value is one message, + // not one problem per file: a zero timeout used to make every file time out, and a bad + // dpi used to reach each child, which printed nothing and was reported as a crash. + let timeout_value = flag(args, "--timeout").unwrap_or("20"); + let timeout = Duration::from_secs( + timeout_value + .parse::() + .ok() + .filter(|secs| *secs > 0) + .ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?, + ); + let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); + parse_dpi(&dpi)?; let files = collect(&positional(args)); if files.is_empty() { return Err("check: no PDF files found".into()); } - let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("20").parse().map_err(|_| "bad --timeout")?); - let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); let exe = std::env::current_exe().map_err(|e| e.to_string())?; let mut results = Vec::new(); let mut counts: std::collections::BTreeMap = Default::default(); diff --git a/apps/pdfcraft-cli/tests/check.rs b/apps/pdfcraft-cli/tests/check.rs index a206df591..7a6236419 100644 --- a/apps/pdfcraft-cli/tests/check.rs +++ b/apps/pdfcraft-cli/tests/check.rs @@ -1,4 +1,5 @@ //! The robustness harness visits each directory once, including symlink aliases and cycles. +//! Its `--timeout` and `--dpi` options are checked before anything is scanned. use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -109,3 +110,22 @@ fn check_follows_directory_and_file_symlinks() { assert_eq!(results[0]["status"], "ok"); assert_eq!(results[0]["file"], file_alias.to_string_lossy().as_ref()); } + +/// `--timeout` and `--dpi` are checked before any input is scanned: a zero timeout used to +/// make every file time out at once, and a dpi that is not a positive finite number used to +/// reach each child, which printed nothing and was reported as a crash per file. +#[test] +fn a_bad_timeout_or_dpi_is_refused_before_scanning() { + let out = Command::new(BIN).args(["check", "no-such-input", "--timeout", "0"]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert_eq!(out.stderr, b"pdfcraft-cli: bad --timeout: expected a positive whole number of seconds, got \"0\"\n"); + for value in ["abc", "0", "-3", "NaN"] { + let out = Command::new(BIN).args(["check", "no-such-input", "--dpi", value]).output().unwrap(); + assert_eq!(out.status.code(), Some(1), "--dpi {value}"); + assert_eq!( + out.stderr, + format!("pdfcraft-cli: bad --dpi: expected a positive number of dots per inch, got {value:?}\n").as_bytes(), + "--dpi {value}" + ); + } +} diff --git a/apps/pdfcraft-cli/tests/edit.rs b/apps/pdfcraft-cli/tests/edit.rs index b35b71ddd..3cd1442d8 100644 --- a/apps/pdfcraft-cli/tests/edit.rs +++ b/apps/pdfcraft-cli/tests/edit.rs @@ -141,9 +141,9 @@ mod one_based_arguments { for destination in ["output.pam", "new.pam", "input.pdf"] { std::fs::write(dir.0.join("output.pam"), b"keep destination").unwrap(); for (command, option, value, diagnostic) in [ - ("render", "--page", "0", "--page must be at least 1"), - ("edit", "--move", "1:0", "--move target must be at least 1"), - ("edit", "--insert-blank", "0", "--insert-blank must be at least 1"), + ("render", "--page", "0", "bad --page: expected a positive page number"), + ("edit", "--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""), + ("edit", "--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""), ] { let out = Command::new(BIN).current_dir(&dir.0).args([command, input, "--out", destination, option, value]).output().unwrap(); assert_eq!(out.status.code(), Some(1), "{command} {input} {option} {value}"); @@ -194,6 +194,33 @@ mod one_based_arguments { } } +/// A value the option cannot hold names the option and the value, and fails before the input +/// file is opened: a missing input still gets the option's own message. +#[test] +fn a_value_an_option_cannot_hold_is_named_before_the_file_is_opened() { + let dir = tmp("values", ""); + for (option, value, diagnostic) in [ + ("--rotate", "1:abc", "bad --rotate degrees: expected a whole number of degrees, got \"abc\""), + ("--rotate", "1:45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got 45"), + ("--rotate", "1:-45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got -45"), + ("--move", "1:abc", "bad --move target: expected a page number of at least 1, got \"abc\""), + ("--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""), + ("--insert-blank", "abc", "bad --insert-blank position: expected a page position of at least 1, got \"abc\""), + ("--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""), + ] { + let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", option, value]).output().unwrap(); + assert_eq!(out.status.code(), Some(1), "{option} {value}"); + assert_eq!(out.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}"); + assert!(out.stdout.is_empty(), "{option} {value}"); + assert!(!dir.join("out.pdf").exists(), "{option} {value}: no output may be written"); + } + // A negative multiple of 90 is still a rotation the engine accepts: the refusal above is + // the multiple-of-90 rule, not a sign problem (the missing input is the next error). + let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", "--rotate", "1:-90"]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert!(!String::from_utf8_lossy(&out.stderr).contains("--rotate degrees"), "{}", String::from_utf8_lossy(&out.stderr)); +} + /// A destination past the end must fail, not silently become an append (#1330). #[test] fn a_destination_past_the_end_fails_and_writes_nothing() { diff --git a/apps/pdfcraft-cli/tests/render.rs b/apps/pdfcraft-cli/tests/render.rs index b0ab24560..0d8776069 100644 --- a/apps/pdfcraft-cli/tests/render.rs +++ b/apps/pdfcraft-cli/tests/render.rs @@ -77,3 +77,27 @@ fn an_unknown_extension_fails_and_writes_nothing() { assert!(stderr.contains(".png, .jpg, .tif or .pam"), "{stderr}"); assert!(!out.exists()); } + +/// `--page` and `--dpi` are checked before the file is opened. A page that is not a positive +/// whole number names the same expectation `text` names; a dpi that parses as an `f32` but +/// cannot be rendered (`0`, a negative value, `NaN`) names the option and the value, instead +/// of reaching the renderer as a scale it refuses with its own internal message. +#[test] +fn a_page_or_dpi_the_renderer_cannot_use_is_refused_before_reading_the_file() { + for (option, value, diagnostic) in [ + ("--page", "abc", "bad --page: expected a positive page number"), + ("--page", "0", "bad --page: expected a positive page number"), + ("--dpi", "abc", "bad --dpi: expected a positive number of dots per inch, got \"abc\""), + ("--dpi", "0", "bad --dpi: expected a positive number of dots per inch, got \"0\""), + ("--dpi", "-3", "bad --dpi: expected a positive number of dots per inch, got \"-3\""), + ("--dpi", "NaN", "bad --dpi: expected a positive number of dots per inch, got \"NaN\""), + ("--dpi", "inf", "bad --dpi: expected a positive number of dots per inch, got \"inf\""), + ] { + let out = tmp("refuse", "out.png"); + // `missing.pdf` is never read: the option's own message is the error. + let r = Command::new(BIN).args(["render", "missing.pdf", "--out", out.to_str().unwrap(), option, value]).output().unwrap(); + assert_eq!(r.status.code(), Some(1), "{option} {value}"); + assert_eq!(r.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}"); + assert!(!out.exists(), "{option} {value}: no output may be written"); + } +} diff --git a/apps/pdfcraft-cli/tests/split.rs b/apps/pdfcraft-cli/tests/split.rs index 7a4a254f8..71523191d 100644 --- a/apps/pdfcraft-cli/tests/split.rs +++ b/apps/pdfcraft-cli/tests/split.rs @@ -1,4 +1,5 @@ //! `split --out-dir DIR` creates DIR when it does not exist yet (#249), and says so when it can't. +//! `--every` must be a positive whole number of pages, checked before the file is opened. use std::path::PathBuf; use std::process::{Command, Output}; @@ -74,3 +75,22 @@ fn an_out_dir_that_cannot_be_created_is_reported_as_such() { assert_eq!(std::fs::read(¬_a_dir).unwrap(), b"in the way"); let _ = std::fs::remove_dir_all(&dir); } + +/// `--every` must be a positive whole number of pages. `0` parses, but the engine used to +/// clamp it to 1 silently; refused here, before the file is opened, it cannot surprise anyone. +#[test] +fn a_non_positive_or_unparseable_every_is_refused_before_the_file_is_opened() { + let dir = workdir("every"); + for value in ["0", "abc"] { + let out_dir = dir.join("parts"); + let out = Command::new(BIN).args(["split", "missing.pdf", "--every", value, "--out-dir", out_dir.to_str().unwrap()]).output().unwrap(); + assert_eq!(out.status.code(), Some(1), "--every {value}"); + assert_eq!( + out.stderr, + format!("pdfcraft-cli: bad --every: expected a positive whole number of pages, got {value:?}\n").as_bytes(), + "--every {value}" + ); + assert!(!out_dir.exists(), "--every {value}: nothing may be written"); + } + let _ = std::fs::remove_dir_all(&dir); +} From 02b9a69242de94e14773699dcf032ee2e3990349 Mon Sep 17 00:00:00 2001 From: Bode512 Date: Fri, 16 Oct 2026 23:42:35 +0200 Subject: [PATCH 10/31] chore(contributors): add Bode512's people.toml entry (sync_github_name) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index da3cfc6a7..f84034f07 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -145,3 +145,6 @@ sync_github_name = false [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.Bode512] +sync_github_name = true From 4a7d556a320f54e57158febbf3bbf49d714e0749 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:46:05 +0800 Subject: [PATCH 11/31] fix(cos): write whole reals past the 32-bit integer range as reals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_real printed a whole real below 1e15 as an integer, and a larger one through Display, which has no decimal point either. So 4e9 or 1e30 was written as an integer token outside the range ISO 32000-2 Annex C gives integers (±2^31-1), and read back as Int (or, in readers with 32-bit integers, overflowed). Whole reals now use the integer form only within that range; past it they keep a trailing '.' (§7.3.3 allows "4."). Raised in the review of #2877. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/cos/src/writer.rs | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index f8d8fd3d8..c95f12200 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -49,8 +49,10 @@ fn write_real(r: f64, out: &mut Vec) { out.push(b'0'); return; } - if r.fract() == 0.0 && r.abs() < 1e15 { - let _ = write!(out, "{}", r as i64); + // A whole number is written as an integer only within the integer range Annex C gives + // (±2³¹−1), so `4e9` or `1e30` stays a real token for readers with 32-bit integers. + if r.fract() == 0.0 && r.abs() <= f64::from(i32::MAX) { + let _ = write!(out, "{}", r as i32); return; } // Plain decimal notation (§7.3.3 has no exponents). Rust's `Display` for f64 prints the @@ -58,6 +60,10 @@ fn write_real(r: f64, out: &mut Vec) { // round-trip exactly (rounding to a fixed number of places visibly shifted shading colours). let s = format!("{r}"); out.extend_from_slice(if s == "-0" { b"0" } else { s.as_bytes() }); + // A whole number past that range prints without a point; the trailing `.` keeps it a real. + if !s.contains('.') && s != "-0" { + out.push(b'.'); + } } fn write_name(n: &[u8], out: &mut Vec) { @@ -662,14 +668,38 @@ mod tests { } } + #[test] + fn whole_reals_past_the_integer_limit_stay_reals() { + for (r, text) in [ + (2_147_483_647.0, "2147483647"), + (-2_147_483_647.0, "-2147483647"), + (2_147_483_648.0, "2147483648."), + (4e9, "4000000000."), + (-4e9, "-4000000000."), + (1e30, "1000000000000000000000000000000."), + (595.0, "595"), + (-0.0, "0"), + ] { + let mut out = Vec::new(); + serialize(&Object::Real(r), &mut out); + assert_eq!(String::from_utf8_lossy(&out), text, "{r}"); + let back = roundtrip(&Object::Real(r)); + if r.abs() > f64::from(i32::MAX) { + assert_eq!(back, Object::Real(r), "{r} must read back as a real"); + } + } + } + proptest! { #[test] fn any_finite_real_round_trips(r in proptest::num::f64::NORMAL | proptest::num::f64::SUBNORMAL | proptest::num::f64::ZERO) { - prop_assume!(r.abs() < 1e15 || r.fract() == 0.0); let back = roundtrip(&Object::Real(r)); match back { Object::Real(b) => prop_assert_eq!(b, r), - Object::Int(i) => prop_assert_eq!(i as f64, r), + Object::Int(i) => { + prop_assert!(i.unsigned_abs() <= i32::MAX as u64, "{r} came back as integer {i}"); + prop_assert_eq!(i as f64, r); + } other => prop_assert!(false, "unexpected {other:?}"), } } From bac52a6d9e37b88a1ba6649c39d5cf9502dd7bf9 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:53:15 +0300 Subject: [PATCH 12/31] fix(save): preserve macOS extended attributes (#2658) --- Cargo.lock | 1 + ROADMAP.md | 2 + crates/automation/Cargo.toml | 3 + crates/automation/tests/automation.rs | 59 +++++++++ crates/platform/README.md | 6 +- crates/platform/src/staging.rs | 168 ++++++++++++++++++++++++++ docs/roadmap.md | 4 +- parity/acrobat-features.toml | 4 +- 8 files changed, 243 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 91ed3c490..962faeb15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4006,6 +4006,7 @@ dependencies = [ "pdfcraft-render", "pdfcraft-xfa", "png 0.18.1", + "rustix 1.1.5", "serde_json", "sha2 0.11.0", "tempfile", diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec0..6cbcfcf68 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log + +- **2026-10-17 (M1, #2658):** macOS saves retain accessible destination extended attributes before atomic publication, including Finder tags and empty/binary values. Metadata errors keep the original and unsaved edit; source symlinks are not followed. Permissions/ACLs, hard links and creation dates remain separate work. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 60bd9eef4..4e3a220ae 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -32,3 +32,6 @@ flate2.workspace = true pdfcraft-model = { workspace = true } pdfcraft-xfa = { workspace = true } image = { version = "0.25", default-features = false, features = ["jpeg"] } + +[target.'cfg(target_os = "macos")'.dev-dependencies] +rustix = { version = "1", features = ["fs"] } diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f3..47e1665b9 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -64,6 +64,65 @@ fn remote_link_pdf(file: &str, destination: Option<&str>) -> Vec { bytes } +#[cfg(target_os = "macos")] +#[test] +fn saving_a_pdf_keeps_the_destinations_extended_attributes() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = workdir("save-xattrs"); + let target = dir.join("a.pdf"); + let tags = b"PdfCraft\n6"; + let binary = b"\0\xffpdfcraft\0"; + let attributes: &[(&str, &[u8])] = &[("com.apple.metadata:_kMDItemUserTags", tags), ("com.pdfcraft.test", binary)]; + for (name, value) in attributes { + setxattr(&target, *name, value, XattrFlags::empty()).unwrap(); + } + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + for full in [false, true] { + ok(&mut a, "page_rotate", json!({"doc": doc, "pages": [1], "degrees": 90})); + let saved = ok(&mut a, "doc_save", json!({"doc": doc, "full": full})); + assert_eq!(saved["incremental"], !full); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = getxattr(&target, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value, "full={full}, {name}"); + } + } + // Save As preserves the destination's metadata, not attributes from the open input file. + let other = dir.join("other.pdf"); + std::fs::write(&other, fixture(1)).unwrap(); + setxattr(&other, "com.pdfcraft.test", b"destination", XattrFlags::empty()).unwrap(); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "other.pdf"})); + let mut bytes = [0u8; 256]; + let len = getxattr(&other, "com.pdfcraft.test", &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"destination"); + assert_eq!(getxattr(&other, "com.apple.metadata:_kMDItemUserTags", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(target_os = "macos")] +#[test] +fn a_metadata_preservation_error_keeps_the_pdf_and_unsaved_edit() { + use rustix::fs::{XattrFlags, setxattr}; + let dir = workdir("save-xattrs-error"); + let target = dir.join("a.pdf"); + let original = std::fs::read(&target).unwrap(); + setxattr(&target, "com.apple.ResourceFork", &vec![42u8; 16 * 1024 * 1024 + 1], XattrFlags::empty()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "page_rotate", json!({"doc": doc, "pages": [1], "degrees": 90})); + let failure = err(&mut a, "doc_save", json!({"doc": doc})); + assert!(failure.to_string().contains("extended attributes"), "{failure}"); + assert!(failure.to_string().contains("Save As"), "{failure}"); + assert_eq!(std::fs::read(&target).unwrap(), original); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["document"]["dirty"], true); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "only the two original PDFs remain"); + // A fresh name remains usable: metadata refusal doesn't discard the accepted edit. + ok(&mut a, "doc_save", json!({"doc": doc, "path": "copy.pdf"})); + assert_ne!(std::fs::read(dir.join("copy.pdf")).unwrap(), original); + std::fs::remove_dir_all(dir).unwrap(); +} + fn protected_fixture(password: &str) -> Vec { use pdfcraft_engine::{Edit, Protection, Session}; let mut session = Session::new(); diff --git a/crates/platform/README.md b/crates/platform/README.md index 6b23752b5..c3dcc076f 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -1,7 +1,11 @@ # pdfcraft-platform -Layer L7 holds operating-system services that both the desktop app (`pdfcraft-ui-egui`) and the automation tools (`pdfcraft-automation`) need. Those two crates sit on the same layer and can't depend on each other, so a helper they share lives here instead of being copied into each (`xtask/src/layers.rs` allows both edges). It uses only the standard library and has no UI dependencies. +Layer L7 holds operating-system services that both the desktop app (`pdfcraft-ui-egui`) and the automation tools (`pdfcraft-automation`) need. Those two crates sit on the same layer and can't depend on each other, so a helper they share lives here instead of being copied into each (`xtask/src/layers.rs` allows both edges). It has no UI dependencies; macOS filesystem calls use the safe `rustix` wrapper. `staging` creates the temporary file an atomic save writes before renaming it over the target. The file is opened with `create_new`, so anything already at the name (a file, a hard link, a symbolic link even when dangling, a folder) is skipped and never written through. On Windows a folder at the name fails with "access denied" rather than "already exists", and is skipped only when something is actually there. Names carry an unpredictable 64-bit suffix from `staging_suffixes` (the standard library's keyed hash, currently SipHash-1-3, keyed from the operating system's random source), which stops a name being planted in advance but is not a CSPRNG; fixed suffixes are only for tests. At most 16 names are tried, and then the save is refused. The target's name is cut to 128 bytes on a character boundary so the staging name fits every system's limit. Each caller keeps its own name form (`.{name}.{suffix}.pdfcraft-tmp` for automation, `.{name}.pdfcraft-{suffix}.tmp` for the desktop save). Writing, closing the file before the rename, renaming and removing the staging file on failure stay with the caller. Synthetic tests pin both name forms, the 128-byte cut, the attempt limit and refusal when no name is left. The planted-file, link, folder and failed-rename tests stay with each caller, where they exercise the whole save. + +On macOS, `write_atomically_with` copies the existing regular destination's accessible extended attributes to the open staging file after writing the data and before flushing and renaming. Finder tags, comments, binary values and empty attributes therefore survive both incremental and full saves (#2658). Save As retains attributes of an existing destination; a new destination does not inherit metadata from the source PDF. Missing files and filesystems without xattr support keep working. The metadata read does not follow the destination's symlink or block on a FIFO. macOS manages the new file's `com.apple.provenance`; it is not copied. Kernel-hidden compression attributes are not requested. + +Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. This does not preserve permission bits, ACLs, hard-link identity or creation dates; other parts of #2658 need separate work. diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index f7200efb9..78ceead9c 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -70,6 +70,8 @@ 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 macOS, accessible extended attributes of an existing regular destination are copied +/// before flushing; a copy failure refuses the save rather than losing metadata (#2658). pub fn write_atomically(path: &Path, bytes: &[u8], form: StagingName) -> std::io::Result<()> { write_atomically_with(path, bytes, form, staging_suffixes(), std::fs::File::sync_all) } @@ -93,6 +95,10 @@ pub fn write_atomically_with( { let mut file = file; file.write_all(bytes)?; + #[cfg(target_os = "macos")] + preserve_target_xattrs(path, &file).map_err(|e| { + std::io::Error::new(e.kind(), format!("could not preserve the file's extended attributes; save a copy with Save As: {e}")) + })?; sync_staged(&file, sync_all)?; } std::fs::rename(&tmp, path) @@ -103,6 +109,63 @@ pub fn write_atomically_with( result } +/// Copy accessible macOS attributes to the staging descriptor before publication. The kernel +/// hides compression bookkeeping unless SHOWCOMPRESSION is requested; leave that option off. +/// Provenance is managed by macOS for the new file, not writable document metadata. +#[cfg(target_os = "macos")] +fn preserve_target_xattrs(path: &Path, staged: &std::fs::File) -> std::io::Result<()> { + use rustix::fs::{Mode, OFlags, XattrFlags, fgetxattr, flistxattr, fsetxattr, open}; + use rustix::io::Errno; + + // A new destination has nothing to retain. Replacing a symlink has always replaced the + // link, not its referent: do not open it to read metadata from a different file. NONBLOCK + // also prevents a FIFO at the destination from hanging the metadata read. + let source = match open(path, OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, Mode::empty()) { + Ok(fd) => std::fs::File::from(fd), + Err(Errno::NOENT | Errno::LOOP) => return Ok(()), + Err(e) => return Err(e.into()), + }; + if !source.metadata()?.is_file() { + return Ok(()); + } + + // Metadata is untrusted too: bound both allocations and total copying, including forks. + const MAX_NAMES_BYTES: usize = 64 * 1024; + const MAX_ATTRIBUTES: usize = 1024; + const MAX_VALUE_BYTES: usize = 16 * 1024 * 1024; + const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024; + let mut names = vec![0u8; MAX_NAMES_BYTES]; + let len = match flistxattr(&source, names.as_mut_slice()) { + Ok(len) => len, + Err(Errno::NOTSUP) => return Ok(()), // No attributes exist on this filesystem. + Err(Errno::RANGE) => return Err(std::io::Error::other("extended attribute name list exceeds 64 KiB")), + Err(e) => return Err(e.into()), + }; + let names = names.get(..len).ok_or_else(|| std::io::Error::other("extended attribute name list exceeds 64 KiB"))?; + let mut total = 0usize; + for (index, name) in names.split(|b| *b == 0).filter(|name| !name.is_empty()).enumerate() { + if index >= MAX_ATTRIBUTES { + return Err(std::io::Error::other("extended attribute count exceeds 1024")); + } + if name == b"com.apple.provenance" { + continue; + } + let name = std::ffi::CString::new(name).map_err(|_| std::io::Error::other("invalid extended attribute name"))?; + let size = fgetxattr(&source, &name, &mut [] as &mut [u8])?; + if size > MAX_VALUE_BYTES || size > MAX_TOTAL_BYTES.saturating_sub(total) { + return Err(std::io::Error::other("extended attributes exceed the 16 MiB per-value or 64 MiB total limit")); + } + // A nonempty buffer distinguishes an empty value from a request for its size. If a + // value grows between these reads, ERANGE refuses the save rather than truncating it. + let mut value = vec![0u8; size.max(1)]; + let len = fgetxattr(&source, &name, value.as_mut_slice())?; + let value = value.get(..len).ok_or_else(|| std::io::Error::other("extended attribute changed size while saving"))?; + fsetxattr(staged, &name, value, XattrFlags::empty())?; + total = total.saturating_add(len); + } + Ok(()) +} + /// Flush the staging file to the disk before it replaces the target. On macOS `sync_all` asks /// for a full flush (`F_FULLFSYNC`), which SMB and FUSE volumes, such as a mounted Cryptomator /// vault, refuse as unsupported: there a plain `fsync` is the strongest flush the volume offers. @@ -215,4 +278,109 @@ mod tests { assert_eq!(names(&dir), ["out.pdf"]); let _ = std::fs::remove_dir_all(&dir); } + + #[cfg(target_os = "macos")] + #[test] + fn atomic_saves_keep_finder_tags_and_binary_extended_attributes() { + use rustix::fs::{XattrFlags, fgetxattr, setxattr}; + let dir = test_dir("xattrs"); + let target = dir.join("out.pdf"); + let attributes: &[(&str, &[u8])] = &[ + ("com.apple.metadata:_kMDItemUserTags", b"PdfCraft\n6"), + ("com.pdfcraft.test.binary", b"\0\xff\x80original\0"), + ("com.pdfcraft.test.empty", b""), + ]; + std::fs::write(&target, b"OLD").unwrap(); + for (name, value) in attributes { + setxattr(&target, *name, value, XattrFlags::empty()).unwrap(); + } + // Both desktop and automation name forms, including a second save of the replacement. + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + write_atomically_with(&target, b"NEW", form, [7], |staged| { + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = fgetxattr(staged, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value, "metadata must be present before flush: {name}"); + } + Ok(()) + }) + .unwrap(); + let saved = std::fs::File::open(&target).unwrap(); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = fgetxattr(&saved, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value); + } + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "macos")] + #[test] + fn a_failed_flush_keeps_the_original_extended_attributes() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-flush"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + setxattr(&target, "com.pdfcraft.test", b"original", XattrFlags::empty()).unwrap(); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [9], |_| Err(std::io::Error::other("flush refused"))).unwrap_err(); + assert!(e.to_string().contains("flush refused")); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let mut value = [0u8; 64]; + let len = getxattr(&target, "com.pdfcraft.test", &mut value[..]).unwrap(); + assert_eq!(&value[..len], b"original"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "macos")] + #[test] + fn oversized_extended_attributes_refuse_the_save_without_replacing_the_target() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-limit"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + // A resource fork can exceed the ordinary attribute limit on macOS. No fixture file + // is committed; only the OS metadata of this temporary synthetic file holds the bytes. + let fork = vec![42u8; 16 * 1024 * 1024 + 1]; + setxattr(&target, "com.apple.ResourceFork", &fork, XattrFlags::empty()).unwrap(); + let mut flushed = false; + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [10], |_| { + flushed = true; + Ok(()) + }) + .unwrap_err(); + assert!(e.to_string().contains("extended attributes"), "{e}"); + assert!(e.to_string().contains("Save As"), "{e}"); + assert!(!flushed, "metadata failure must stop before flush and rename"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let size = getxattr(&target, "com.apple.ResourceFork", &mut [] as &mut [u8]).unwrap(); + assert_eq!(size, fork.len()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "macos")] + #[test] + fn replacing_a_symlink_does_not_copy_attributes_from_its_referent() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-symlink"); + let outside = test_dir("xattrs-outside").join("private.pdf"); + std::fs::write(&outside, b"PRIVATE").unwrap(); + setxattr(&outside, "com.pdfcraft.test", b"private metadata", XattrFlags::empty()).unwrap(); + let target = dir.join("out.pdf"); + std::os::unix::fs::symlink(&outside, &target).unwrap(); + write_atomically(&target, b"NEW", StagingName::TagThenSuffix).unwrap(); + assert_eq!(std::fs::read(&outside).unwrap(), b"PRIVATE"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert!(!target.symlink_metadata().unwrap().file_type().is_symlink()); + let mut bytes = [0u8; 64]; + assert_eq!(getxattr(&target, "com.pdfcraft.test", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR); + let len = getxattr(&outside, "com.pdfcraft.test", &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"private metadata"); + std::fs::remove_dir_all(dir).unwrap(); + std::fs::remove_dir_all(outside.parent().unwrap()).unwrap(); + } } diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f..17d12ff03 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-17 - **Last updated:** 2026-10-17 - **Change:** minor (M1: macOS atomic save retains destination xattrs; estimates unchanged) + > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -97,7 +99,7 @@ total matches the parity estimate. | M | Milestone | Plan size (h) | Done | Remaining (h, 2026-10-10) | Notes | |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). macOS atomic saves retain accessible destination xattrs, including Finder tags; a metadata failure keeps the original and the unsaved edit (#2658, metadata only). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6dbc34f35..2fc5d9809 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -286,8 +286,8 @@ milestone = "M1" status = "shipped" commands = ["file.save"] tools = ["doc_save"] -evidence = ["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"] -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)." +evidence = ["crates/platform/src/staging.rs::atomic_saves_keep_finder_tags_and_binary_extended_attributes", "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"] +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 or metadata limits 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]] id = "core.save-as-full-rewrite" From 92990b2e2c728e81aef924b39d8c3932f6379a9e Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:09:57 +0800 Subject: [PATCH 13/31] M3: layer_set shows or hides a layer, and page_render draws with the document's view settings (#2986) The Layers panel had no automation tool: doc_info listed the layers, but nothing could show or hide one, so layer visibility could not be checked headlessly. layer_set does what the panel's eye icon does, naming a layer by name (every group with that name, as CAD exporters repeat names) or by its 1-based position in doc_info's list. page_render built its own RenderConfig with only the password and the reading colours, so it ignored layer visibility and comments_hide as well: hiding comments through the tool changed nothing it drew. The automation renderer now takes the document's own RenderConfig (Session::render_config), and both tools drop the cached renderer when they change a setting. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/automation/src/comments.rs | 5 +- crates/automation/src/layers.rs | 47 +++++++++++++++ crates/automation/src/lib.rs | 12 ++-- crates/automation/src/tools.rs | 5 ++ crates/automation/tests/automation.rs | 85 +++++++++++++++++++++++++++ crates/engine/src/lib.rs | 6 ++ parity/acrobat-features.toml | 6 +- 7 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 crates/automation/src/layers.rs diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 302688f72..9c3a51c5f 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -589,7 +589,10 @@ impl Automation { pub(crate) fn comments_hide(&mut self, a: &Args) -> Result { let id = self.doc(a)?.id; let hidden = a.opt_bool("hidden")?.unwrap_or(true); - self.session.set_hide_comments(id, hidden); + if self.session.set_hide_comments(id, hidden) { + // The cached renderer drew with the old setting; page_render must read it again. + self.renderers.remove(&id); + } Ok(json!({ "hidden": hidden })) } diff --git a/crates/automation/src/layers.rs b/crates/automation/src/layers.rs new file mode 100644 index 000000000..7cb0817a4 --- /dev/null +++ b/crates/automation/src/layers.rs @@ -0,0 +1,47 @@ +//! Layers (optional content groups) as a tool: the Layers panel's eye icon (#2986, AGENTS.md §3). +//! +//! A per-document view setting, like hidden comments: `page_render` draws with it, and the file +//! itself never changes. + +use serde_json::{Value, json}; + +use crate::{Args, Automation, Result, ToolError}; + +impl Automation { + /// Show or hide a layer, named by `layer`: its name as `doc_info` lists it, or its 1-based + /// position in that list. A name picks every layer that has it, since CAD exporters often + /// write one group per page or per drawing under the same name. + pub(crate) fn layer_set(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let visible = a.opt_bool("visible")?.ok_or_else(|| ToolError::InvalidArgs("missing argument visible".into()))?; + let layers = &doc.info.layers; + let targets: Vec = match a.get("layer") { + Some(Value::String(name)) => layers.iter().enumerate().filter(|(_, l)| l.name == *name).map(|(i, _)| i).collect(), + Some(v) => match v.as_u64() { + Some(n) if n >= 1 && usize::try_from(n - 1).is_ok_and(|i| i < layers.len()) => vec![(n - 1) as usize], + Some(_) => Vec::new(), + None => return Err(ToolError::InvalidArgs("layer must be a layer name or its 1-based position in doc_info's layers".into())), + }, + None => return Err(ToolError::InvalidArgs("missing argument layer".into())), + }; + if targets.is_empty() { + let names: Vec<&str> = layers.iter().map(|l| l.name.as_str()).collect(); + return Err(ToolError::InvalidArgs(if names.is_empty() { + "the document has no layers".into() + } else { + format!("no such layer; the document's layers are: {}", names.join(", ")) + })); + } + let mut changed = false; + for i in targets { + changed |= self.session.set_layer_visible(id, i, visible); + } + if changed { + // The cached renderer drew with the old visibility; page_render must read it again. + self.renderers.remove(&id); + } + let layers = &self.doc(a)?.info.layers; + Ok(json!({ "layers": layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::>() })) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 88856305b..116bdc70a 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -21,6 +21,7 @@ mod content; mod conventions; pub mod file_access; mod forms; +mod layers; mod links; #[cfg(feature = "mcp")] pub mod mcp; @@ -838,6 +839,7 @@ impl Automation { "comment_mark" => self.comment_mark(&a)?, "comment_lock" => self.comment_lock(&a)?, "comments_hide" => self.comments_hide(&a)?, + "layer_set" => self.layer_set(&a)?, "view_reading_colors" => self.view_reading_colors(&a)?, "comments_summarize" => self.comments_summarize(&a)?, "comment_edit" => self.comment_edit(&a)?, @@ -1791,11 +1793,11 @@ impl Automation { fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> { let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; - let colors = self.session.reading_colors(id).unwrap_or_default(); - let fresh = || { - let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), reading_colors: colors, ..Default::default() }; - (doc.display.clone(), PageRenderer::new(doc.display.clone(), config)) - }; + // The document's own view settings: layer visibility, hidden comments, reading colours + // (tools that change one drop this cache so the next render reads it again). + let config = + self.session.render_config(id).unwrap_or_else(|| RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }); + let fresh = || (doc.display.clone(), PageRenderer::new(doc.display.clone(), config.clone())); let entry = self.renderers.entry(id).or_insert_with(fresh); if !Arc::ptr_eq(&entry.0, &doc.display) { *entry = fresh(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 5e89aef78..7af573bf0 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -757,6 +757,11 @@ pub fn tools() -> Vec { comment_ref(json!({ "locked": { "type": "boolean", "description": "Default true." } })), &["doc"], )), + t("layer_set", "Show or hide a layer", "Show or hide a layer (optional content group), as the Layers panel's eye icon does. layer is its name as doc_info lists it (every layer with that name) or its 1-based position in that list. A view setting: page_render draws with it, and the file doesn't change. Returns every layer with its visibility.") + .with(schema( + json!({ "doc": doc(), "layer": { "type": ["string", "integer"], "description": "A layer name, or a 1-based position in doc_info's layers." }, "visible": { "type": "boolean" } }), + &["doc", "layer", "visible"], + )), t("comments_hide", "Hide all comments", "Hide or show every comment on the page (fields and links still draw). A view setting: the file doesn't change.") .cmd("comment.hide_all") .with(schema(json!({ "doc": doc(), "hidden": { "type": "boolean", "description": "Default true." } }), &["doc"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f3..bf4de7417 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -6262,6 +6262,91 @@ fn pixel(pixels: &[u8], x: u32, y: u32) -> [u8; 4] { pixels[at..at + 4].try_into().unwrap() } +/// A 300 × 100 page: red in layer "Walls" (left), green in "Doors" (middle), blue in a second +/// group also named "Walls" (right), and a black Square comment along the bottom. +fn layered_pdf() -> Vec { + let content = b"/OC /W1 BDC 1 0 0 rg 10 30 80 60 re f EMC /OC /D BDC 0 1 0 rg 110 30 80 60 re f EMC /OC /W2 BDC 0 0 1 rg 210 30 80 60 re f EMC"; + let ap = b"0 g 0 0 280 10 re f"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /Order [5 0 R 6 0 R 7 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 100] /Contents 4 0 R /Annots [8 0 R] /Resources << /Properties << /W1 5 0 R /D 6 0 R /W2 7 0 R >> >> >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(), + b"<< /Type /OCG /Name (Walls) >>".to_vec(), + b"<< /Type /OCG /Name (Doors) >>".to_vec(), + b"<< /Type /OCG /Name (Walls) >>".to_vec(), + b"<< /Type /Annot /Subtype /Square /Rect [10 2 290 12] /C [0 0 0] /AP << /N 9 0 R >> >>".to_vec(), + [format!("<< /Type /XObject /Subtype /Form /BBox [0 0 280 10] /Length {} >>\nstream\n", ap.len()).as_bytes(), ap, b"\nendstream"].concat(), + ]; + 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", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #2986: `layer_set` hides and shows layers in what `page_render` draws, by name (every group +/// with that name) or by position; `comments_hide` now changes `page_render` too. Neither +/// changes the file. +#[test] +fn layers_and_hidden_comments_change_what_page_render_draws() { + let dir = workdir("layer-set"); + std::fs::write(dir.join("layers.pdf"), layered_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "layers.pdf" }))["doc"].as_u64().unwrap(); + let at = |a: &mut Automation, x: u32, y: u32| { + let (w, _, px) = a.render_pixels(doc, 1, 1.0).unwrap(); + let i = ((y * w + x) * 4) as usize; + [px.as_ref()[i], px.as_ref()[i + 1], px.as_ref()[i + 2]] + }; + // Points (50, 40) red, (150, 40) green, (250, 40) blue, (150, 93) the comment; y down. + assert_eq!( + [at(&mut a, 50, 40), at(&mut a, 150, 40), at(&mut a, 250, 40), at(&mut a, 150, 93)], + [[255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 0, 0]] + ); + + let r = ok(&mut a, "layer_set", json!({ "doc": doc, "layer": "Walls", "visible": false })); + assert_eq!( + r["layers"], + json!([{ "name": "Walls", "visible": false }, { "name": "Doors", "visible": true }, { "name": "Walls", "visible": false }]) + ); + assert_eq!( + [at(&mut a, 50, 40), at(&mut a, 150, 40), at(&mut a, 250, 40)], + [[255, 255, 255], [0, 255, 0], [255, 255, 255]], + "both groups named Walls hide" + ); + + ok(&mut a, "layer_set", json!({ "doc": doc, "layer": 3, "visible": true })); + assert_eq!([at(&mut a, 50, 40), at(&mut a, 250, 40)], [[255, 255, 255], [0, 0, 255]], "position 3 is the second Walls only"); + + ok(&mut a, "comments_hide", json!({ "doc": doc })); + assert_eq!(at(&mut a, 150, 93), [255, 255, 255], "hidden comments are not drawn"); + ok(&mut a, "comments_hide", json!({ "doc": doc, "hidden": false })); + assert_eq!(at(&mut a, 150, 93), [0, 0, 0]); + + for (args, what) in [ + (json!({ "doc": doc, "layer": "Roof", "visible": false }), "an unknown name"), + (json!({ "doc": doc, "layer": 0, "visible": false }), "position 0"), + (json!({ "doc": doc, "layer": 4, "visible": false }), "a position past the end"), + (json!({ "doc": doc, "layer": true, "visible": false }), "a layer that is neither"), + (json!({ "doc": doc, "layer": "Doors" }), "no visible"), + ] { + assert!(matches!(a.call("layer_set", &args), Err(ToolError::InvalidArgs(_))), "{what} must be refused"); + } + let list = ok(&mut a, "doc_list", json!({})); + assert_eq!(list["documents"][0]["dirty"], json!(false), "a view setting leaves the document clean: {list}"); +} + /// The `view_reading_colors` tool changes what `page_render` draws — text and background remap, /// images never do — without touching the file, and its errors name what's wrong (#897). #[test] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3432cd868..ed635389a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4760,6 +4760,12 @@ impl Session { Some(self.get(id)?.config.reading_colors) } + /// How the document's pages are drawn for viewing: its password and view settings (layer + /// visibility, hidden comments, reading colours). `None` when no such document is open. + pub fn render_config(&self, id: DocId) -> Option { + Some(self.get(id)?.config.clone()) + } + /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment /// with its number, author, type, date, text and replies, grouped by page. pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6dbc34f35..add7345f7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1321,9 +1321,9 @@ tier = "P0" milestone = "M3" status = "shipped" commands = [] -tools = ["doc_info"] -evidence = ["crates/render/src/raster.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits"] -notes = "" +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"] +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]] id = "view.layers-reset" From 8d469f9bb031dd0437c78afae33d7101826cceb8 Mon Sep 17 00:00:00 2001 From: mawfy Date: Sat, 17 Oct 2026 12:11:44 -0500 Subject: [PATCH 14/31] docs(roadmap): record #2662, the audit's one-word cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix landed as #2698 (9dfc91e0) with its parity evidence, but no progress-doc record did: add the status line and progress-log entry in ROADMAP.md, the status line, revision row and M8 milestone note in docs/roadmap.md, and gap 6's evidence and revision history in docs/gaps.md — the wrong-result backlog the issue belongs to. Estimates unchanged. --- ROADMAP.md | 2 ++ docs/gaps.md | 6 +++++- docs/roadmap.md | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec0..a93dbb71f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) @@ -157,6 +158,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-17 (M8, gap 6, #2662):** A filled cover over a long line counts when it hides half of any one word, not only 40% of the line's words or box, so a black box over one number in a long line — or an unapplied redaction mark over it — no longer audits clean while the number is still extractable; a sliver under half of every word it touches and under 40% of the line is still not flagged. Issue #2662, fixed by #2698, with the parity evidence updated. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/docs/gaps.md b/docs/gaps.md index a3ce0d1f1..87e1b09b2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,6 @@ # Where PdfCraft falls short of Acrobat Pro +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6: #2662 redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (gap 6: #1910 Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -139,7 +140,9 @@ update the numbers. read, selected and scrolled into view (#1910). Stamps placed on a rotated page read upright (#1398). Another batch keeps a spaced XFA radio value in the datasets (#1319), replaces only the selected pages when they are not adjacent (#1305), and leaves unrelated page streams in place - when a figure is marked decorative (#1324). The rest of the backlog is untriaged. + when a figure is marked decorative (#1324). A black box over one word of a long line, and an + unapplied redaction mark over it, audit as a cover instead of a clean bill (#2662). The rest of + the backlog is untriaged. - **Impact:** every one is a user who lost trust. Crash reports outrank feature work (`AGENTS.md` §4). - **Estimate:** 45–80 h (≈ 0.2–0.4 h per issue). **Doc:** this file. @@ -378,6 +381,7 @@ update the numbers. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Gap 6: the redaction audit counts a cover over one word of a long line instead of a clean bill (#2662, fixed by #2698). Estimates unchanged. | | 2026-10-15 | minor | Gap 12: the text oracle scores reading order as well as word F1, which is a bag of words and cannot see order; the first measurement separated 18 improvements from 6 regressions in a change F1 called unchanged on 26 of 29 files. Estimates unchanged. | | 2026-10-15 | patch | Gap 24: record localization and installers as partial (unusable Chinese, Telugu and Arabic; unsigned Linux packages) | | 2026-10-13 | minor | Gap 6: Organize Pages selects and reveals the current page on open, including after an earlier grid selection elsewhere (#1910). Estimates unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f..4a5d1596f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -124,7 +125,7 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | -| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | +| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | @@ -179,6 +180,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | M8, gap 6 #2662: a filled cover that hides half of any one word of a long line counts as a redaction-audit finding, applied or not; a sliver under half of every word stays unflagged. Estimates unchanged. | | 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | From 34e4aac28e5b951907c365e30f5c50ea87325d84 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:41:32 +0300 Subject: [PATCH 15/31] M1: keep roadmap status blockquotes valid --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 54bf9906b..763da71ac 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,7 +2,7 @@ > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M1, #2658: reject partial extended-attribute copies after a size change; estimates unchanged) > **Last reviewed:** 2026-10-17 - **Last updated:** 2026-10-17 - **Change:** minor (M1: macOS atomic save retains destination xattrs; estimates unchanged) - +> > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) From 3086ee7a3d2eb2b9bc09781af444d202d1280772 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Mon, 19 Oct 2026 02:45:16 +0200 Subject: [PATCH 16/31] M11: reject hostile optimizer image dimensions --- ROADMAP.md | 2 ++ crates/optimize/src/images.rs | 35 +++++++++++++++++++++++++----- crates/optimize/tests/optimize.rs | 36 +++++++++++++++++++++++++++++++ docs/roadmap.md | 2 +- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bad75fc3b..e0e7f5776 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -171,6 +171,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-19 (M11, #2178):** Optimize checks image and soft-mask dimensions and byte counts before decoding; hostile-dimension regressions pass. Estimates unchanged. + - **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged. - **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved. diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 8cfa82674..cd9e0744c 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -230,15 +230,19 @@ fn is_dct(s: &Stream) -> Option { /// 8-bit samples of an image (`n` components), decoded. pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { - let (w, h) = (s.dict.int(b"Width")? as usize, s.dict.int(b"Height")? as usize); + let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { + return None; + } + 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()?; // 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() == w * h * n).then_some(v) + (v.len() == bytes).then_some(v) } else { let v = s.decoded().ok()?; - (v.len() >= w * h * n).then(|| v[..w * h * n].to_vec()) + Some(v.get(..bytes)?.to_vec()) } } @@ -316,7 +320,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us return None; } let was_dct = is_dct(s)?; - let (w, h) = (d.int(b"Width")? as u32, d.int(b"Height")? as u32); + let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?); if w == 0 || h == 0 || (w as u64) * (h as u64) > MAX_PIXELS { return None; } @@ -347,7 +351,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us if m.dict.int(b"BitsPerComponent") != Some(8) || m.dict.contains(b"Matte") || m.dict.contains(b"Decode") { return None; } - let (mw, mh) = (m.dict.int(b"Width")? as u32, m.dict.int(b"Height")? as u32); + let (mw, mh) = (u32::try_from(m.dict.int(b"Width")?).ok()?, u32::try_from(m.dict.int(b"Height")?).ok()?); let mpx = pixels(m, 1)?; let mpx = resample(&mpx, 1, mw, mh, nw, nh)?; smask = Some((mr, image_stream(&m.dict, &mpx, 1, nw, nh, Compression::Flate, false)?)); @@ -409,3 +413,24 @@ pub(crate) fn run( } Ok(()) } + +#[cfg(test)] +mod dimension_tests { + use super::*; + + #[test] + fn pixel_reader_rejects_hostile_dimensions_before_decoding() { + for (width, height, channels) in [(-1, 1, 1), (0, 1, 1), (i64::from(u32::MAX) + 101, 1, 1), (80_000_001, 1, 1), (2, 2, usize::MAX)] { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(width)); + dict.set(b"Height".to_vec(), Object::Int(height)); + let stream = Stream::from_raw(dict, vec![0; 4]); + assert!(pixels(&stream, channels).is_none(), "{width} x {height} x {channels}"); + } + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(2)); + dict.set(b"Height".to_vec(), Object::Int(2)); + let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]); + assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4])); + } +} diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2a83e8d6a..4d16f59cd 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1022,3 +1022,39 @@ fn images_are_not_resampled_when_some_uses_could_not_be_measured() { let refs = pdfcraft_annot::page_refs(&doc).unwrap(); assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); } + +#[test] +fn a_hostile_image_size_is_left_alone_not_multiplied_into_overflow() { + // /Width and /Height are file-controlled: 2^32 + 100 wraps to 100 when read as u32, while the + // real values overflow `w * h * n`. The image must be skipped, not sized by a wrapped product. + let mut doc = Document::new_empty(); + let side = i64::from(u32::MAX) + 101; + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(side)); + d.set(b"Height".to_vec(), Object::Int(side)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let hostile = doc.add(Object::Stream(Stream::flate(d, &[7u8; 256]))); + page(&mut doc, &[("Im", hostile)], "q 1 0 0 1 10 10 cm /Im Do Q"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + assert_eq!(stream(&doc, hostile).dict.int(b"Width"), Some(side), "the image is left as it was"); +} + +#[test] +fn a_hostile_soft_mask_leaves_its_image_unchanged() { + let mut doc = Document::new_empty(); + let mask = image(&mut doc, 400, 400, 1, false, None); + let mut hostile = stream(&doc, mask); + hostile.dict.set(b"Width".to_vec(), Object::Int(i64::from(u32::MAX) + 401)); + doc.set(mask, Object::Stream(hostile.clone())); + let photo = image(&mut doc, 400, 400, 3, false, Some(mask)); + let before = stream(&doc, photo); + page(&mut doc, &[("Photo", photo)], "q 72 0 0 72 10 10 cm /Photo Do Q"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + assert_eq!(stream(&doc, photo), before); + assert_eq!(stream(&doc, mask), hostile); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index e1fdce650..6dcb6658b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -145,7 +145,7 @@ total matches the parity estimate. | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | -| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | +| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). | From ca8ebb0bb7681bc5695f6f3efe156dbf0d64dd96 Mon Sep 17 00:00:00 2001 From: Christoph Ortenburg Date: Thu, 15 Oct 2026 20:32:20 -0400 Subject: [PATCH 17/31] fix(automation): tools on a file the viewer reads fewer pages of answer instead of panicking `link_list` and `form_set_props` indexed the viewer's pages with a page number from the editing layer. On a file whose page tree has a dangling `/Kids` entry (#2660) the viewer sees three pages and the editing layer six, so `link_list` panicked ("the len is 3 but the index is 3") and so did moving a field on one of the later pages. Rectangles on a page the viewer could not read are now reported unchanged, as `doc_info` already reports them (`link_list`, `comment_list`, `content_list`); `comment_list` no longer borrows the last page's geometry for them. Placing a rect on such a page is an error (`form_set_props`, and `sign` when the viewer has no page at all). Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/comments.rs | 4 +- crates/automation/src/content.rs | 6 +- crates/automation/src/forms.rs | 2 +- crates/automation/src/links.rs | 5 +- crates/automation/src/signing.rs | 2 +- crates/automation/tests/hostile_args.rs | 111 ++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 10 deletions(-) diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 302688f72..4effebf11 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -173,7 +173,6 @@ impl Automation { let only = a.opt_int("page")?.map(|p| (p.max(1) - 1) as usize); let all = self.comments(a)?; let view = |c: &Annotation| -> Value { - let p = &doc.info.pages[c.page.min(doc.info.pages.len().saturating_sub(1))]; json!({ "id": c.name, "page": c.page + 1, @@ -182,7 +181,8 @@ impl Automation { "author": c.author, "contents": c.contents, "modified": c.modified, - "rect": rect_to_view(p, c.rect), + // Unchanged on a page the viewer could not read (#2660), as `doc_info` reports it. + "rect": crate::view_rect(&doc.info, c.page, c.rect), "color": c.color.map(hex), }) }; diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index faefc5fa7..6d1c86002 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -73,9 +73,9 @@ impl Automation { .iter() .map(|it| { let index = per_page.entry(it.page).or_default(); - let ph = doc.info.pages[it.page].height as f64; - let mut v = - json!({ "page": it.page + 1, "index": *index + 1, "rect": to_view(it.content.rect(), ph).map(|x| (x * 100.0).round() / 100.0) }); + // Unchanged on a page the viewer could not read (#2660), like `crate::view_rect`. + let rect = doc.info.pages.get(it.page).map_or(it.content.rect(), |p| to_view(it.content.rect(), p.height as f64)); + let mut v = json!({ "page": it.page + 1, "index": *index + 1, "rect": rect.map(|x| (x * 100.0).round() / 100.0) }); *index += 1; match &it.content { AddedContent::Text(t) => { diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index f2eab5ddc..054303d4a 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -405,7 +405,7 @@ impl Automation { let r: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("rect must be 4 numbers".into()))?; let page = f.widgets.first().and_then(|w| w.page).ok_or_else(|| failed(format!("{name} is not on a page")))?; - let p = &doc.info.pages[page]; + let p = doc.info.pages.get(page).ok_or_else(|| failed(format!("{name} is on page {}, which could not be read", page + 1)))?; let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); Some((0, [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])) } diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index cd0fb9da0..8058d5639 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -70,9 +70,8 @@ impl Automation { .links() .iter() .map(|l| { - let p = &doc.info.pages[l.page]; - let (v0, v1) = (p.user_to_view(l.rect[0] as f32, l.rect[1] as f32), p.user_to_view(l.rect[2] as f32, l.rect[3] as f32)); - let rect = [v0[0].min(v1[0]), v0[1].min(v1[1]), v0[0].max(v1[0]), v0[1].max(v1[1])].map(|x| (x * 100.0).round() / 100.0); + // The link's page may be one the viewer could not read (#2660): rect unchanged then. + let rect = crate::view_rect(&doc.info, l.page, l.rect.map(|x| x as f32)); let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible }); // Where an in-document link goes, as the viewer resolves it: named destinations // included, with the position on the page. diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index 94fc4bdec..c7a043c89 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -208,7 +208,7 @@ impl Automation { }; let rect = match a.nums::<4>("rect")? { Some(r) => { - let p = &doc.info.pages[page]; + let p = doc.info.pages.get(page).ok_or_else(|| bad("the document has no page to put the signature on"))?; let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); let u = [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]; if u[2] - u[0] < 4.0 || u[3] - u[1] < 4.0 { diff --git a/crates/automation/tests/hostile_args.rs b/crates/automation/tests/hostile_args.rs index 607696899..b7fd0f164 100644 --- a/crates/automation/tests/hostile_args.rs +++ b/crates/automation/tests/hostile_args.rs @@ -336,3 +336,114 @@ fn listing(dir: &Path) -> Vec<(PathBuf, u64)> { out.sort(); out } + +/// A six-page file whose fourth `/Kids` entry points at an object that does not exist (#2660), +/// with a link, a URI link and a comment on every page and a form field on the last. Readers +/// disagree on such a file: the viewer stopped at the dangling entry and saw three pages, while +/// the editing layer sees all six, so annotations come back on pages the viewer does not have. +fn dangling_kid_pdf() -> Vec { + let pages: Vec = (0..6).map(|i| 10 + 2 * i).collect(); + let mut kids: Vec = pages[..3].to_vec(); + kids.push(77); // does not exist + kids.extend(&pages[3..]); + let kids: Vec = kids.iter().map(|k| format!("{k} 0 R")).collect(); + let mut objs: Vec<(u32, String)> = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [50 0 R] >> >>".into()), + (2, format!("<< /Type /Pages /Kids [{}] /Count 6 >>", kids.join(" "))), + (90, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()), + ]; + for (i, &page) in pages.iter().enumerate() { + let text = format!("BT /F1 24 Tf 50 300 Td (Page {}) Tj ET", i + 1); + let field = if i == 5 { " 50 0 R" } else { "" }; + objs.push(( + page, + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources << /Font << /F1 90 0 R >> >> /Contents {} 0 R \ + /Annots [<< /Type /Annot /Subtype /Link /Rect [10 10 100 40] /Dest [10 0 R /Fit] >> \ + << /Type /Annot /Subtype /Link /Rect [10 50 100 80] /A << /S /URI /URI (https://example.com/) >> >> \ + << /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /T (author) >>{field}] >>", + page + 1 + ), + )); + objs.push((page + 1, format!("<< /Length {} >>\nstream\n{text}\nendstream", text.len()))); + } + objs.push((50, "<< /FT /Tx /T (name) /Type /Annot /Subtype /Widget /Rect [50 100 250 130] /P 20 0 R >>".into())); + objs.sort_by_key(|(n, _)| *n); + + let size = 100; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = vec![None; size]; + for (num, body) in &objs { + if let Some(slot) = offsets.get_mut(*num as usize) { + *slot = Some(out.len()); + } + out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for offset in offsets.iter().skip(1) { + out.extend_from_slice( + match offset { + Some(o) => format!("{o:010} 00000 n \n"), + None => "0000000000 65535 f \n".to_string(), + } + .as_bytes(), + ); + } + out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out +} + +#[test] +fn no_tool_panics_when_the_viewer_sees_fewer_pages_than_the_file_has() { + // #2660: `link_list` indexed the viewer's pages with a page number from the editing layer + // ("index out of bounds: the len is 3 but the index is 3"). Every tool, on a fresh copy of a + // file where the two disagree, must answer with a result or an error. + let root = tmp("dangling-kid"); + let pdf = dangling_kid_pdf(); + let mut failures = Vec::new(); + for def in tools() { + // Written again for every tool: tools that save write to `in.pdf`, the plausible path. + std::fs::write(root.join("in.pdf"), &pdf).expect("fixture"); + let mut a = Automation::new().with_root(&root).expect("root"); + let opened = a.call("doc_open", &json!({ "path": "in.pdf" })).expect("open the damaged file"); + let doc = opened + .into_iter() + .find_map(|c| match c { + pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(), + pdfcraft_automation::Content::Png { .. } => None, + }) + .expect("the opened document has an id"); + if let Err(e) = guarded(&mut a, def.name, &Value::Object(plausible_with(&def, Some(doc)))) { + failures.push(e); + } + match def.name { + // The field sits on the sixth page; a rect is placed on the field's own page. + "form_set_props" => { + if let Err(e) = guarded(&mut a, def.name, &json!({ "doc": doc, "field": "name", "rect": [10, 10, 60, 30] })) { + failures.push(e); + } + } + // Listing still reports what is on the pages the viewer lacks, not just survives it. + "link_list" => { + let listed = catch_unwind(AssertUnwindSafe(|| a.call("link_list", &json!({ "doc": doc })))); + let count = match listed { + Ok(Ok(content)) => content.into_iter().find_map(|c| match c { + pdfcraft_automation::Content::Json(v) => v["count"].as_u64(), + pdfcraft_automation::Content::Png { .. } => None, + }), + _ => None, + }; + if count != Some(12) { + failures.push(format!("link_list listed {count:?} links, not the file's 12")); + } + } + _ => {} + } + } + for f in &failures { + eprintln!(" {f}"); + } + let _ = std::fs::remove_dir_all(&root); + assert!(failures.is_empty(), "{} tool(s) panicked; the first is: {}", failures.len(), failures.first().map_or("", String::as_str)); +} From 85da56ed244b9427ff6211d3e363c559f47036fe Mon Sep 17 00:00:00 2001 From: Christoph Ortenburg Date: Mon, 19 Oct 2026 14:43:33 -0400 Subject: [PATCH 18/31] test(automation): pin the content_list guard and form_set_props' refusal (#2660) Review on #2715: reverting only the content_list guard still passed. The fixture now has text added by PdfCraft (`/PCAdded`) on page 6, and content_list must report it at its unchanged rect [70 70 150 100]. form_set_props must refuse a rect for the field on page 6 with "name is on page 6, which could not be read" and leave form_fields unchanged. Reverting any one of the link, content or form guards now fails the test. Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/hostile_args.rs | 76 ++++++++++++++++++------- 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/crates/automation/tests/hostile_args.rs b/crates/automation/tests/hostile_args.rs index b7fd0f164..d393e3726 100644 --- a/crates/automation/tests/hostile_args.rs +++ b/crates/automation/tests/hostile_args.rs @@ -338,7 +338,8 @@ fn listing(dir: &Path) -> Vec<(PathBuf, u64)> { } /// A six-page file whose fourth `/Kids` entry points at an object that does not exist (#2660), -/// with a link, a URI link and a comment on every page and a form field on the last. Readers +/// with a link, a URI link and a comment on every page, and a form field and text added by +/// PdfCraft (its `/PCAdded` marker) on the last. Readers /// disagree on such a file: the viewer stopped at the dangling entry and saw three pages, while /// the editing layer sees all six, so annotations come back on pages the viewer does not have. fn dangling_kid_pdf() -> Vec { @@ -354,20 +355,27 @@ fn dangling_kid_pdf() -> Vec { ]; for (i, &page) in pages.iter().enumerate() { let text = format!("BT /F1 24 Tf 50 300 Td (Page {}) Tj ET", i + 1); - let field = if i == 5 { " 50 0 R" } else { "" }; + let (field, contents) = if i == 5 { (" 50 0 R", format!("[{} 0 R 60 0 R]", page + 1)) } else { ("", format!("{} 0 R", page + 1)) }; objs.push(( page, format!( - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources << /Font << /F1 90 0 R >> >> /Contents {} 0 R \ + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources << /Font << /F1 90 0 R >> >> /Contents {contents} \ /Annots [<< /Type /Annot /Subtype /Link /Rect [10 10 100 40] /Dest [10 0 R /Fit] >> \ << /Type /Annot /Subtype /Link /Rect [10 50 100 80] /A << /S /URI /URI (https://example.com/) >> >> \ - << /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /T (author) >>{field}] >>", - page + 1 + << /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /T (author) >>{field}] >>" ), )); objs.push((page + 1, format!("<< /Length {} >>\nstream\n{text}\nendstream", text.len()))); } objs.push((50, "<< /FT /Tx /T (name) /Type /Annot /Subtype /Widget /Rect [50 100 250 130] /P 20 0 R >>".into())); + let added = "BT /F1 12 Tf 72 80 Td (AddedSample) Tj ET"; + objs.push(( + 60, + format!( + "<< /PCMark /Added /PCAdded << /Kind /Text /Rect [70 70 150 100] /Text (AddedSample) /Size 12 >> /Length {} >>\nstream\n{added}\nendstream", + added.len() + ), + )); objs.sort_by_key(|(n, _)| *n); let size = 100; @@ -400,6 +408,20 @@ fn no_tool_panics_when_the_viewer_sees_fewer_pages_than_the_file_has() { // ("index out of bounds: the len is 3 but the index is 3"). Every tool, on a fresh copy of a // file where the two disagree, must answer with a result or an error. let root = tmp("dangling-kid"); + // The JSON a call answers with, or the error it gave. + let answer = |a: &mut Automation, tool: &str, args: &Value| -> std::result::Result { + match catch_unwind(AssertUnwindSafe(|| a.call(tool, args))) { + Ok(Ok(content)) => content + .into_iter() + .find_map(|c| match c { + pdfcraft_automation::Content::Json(v) => Some(v), + pdfcraft_automation::Content::Png { .. } => None, + }) + .ok_or_else(|| format!("{tool} answered without JSON")), + Ok(Err(e)) => Err(format!("{tool} failed: {e:?}")), + Err(_) => Err(format!("{tool} panicked")), + } + }; let pdf = dangling_kid_pdf(); let mut failures = Vec::new(); for def in tools() { @@ -418,26 +440,36 @@ fn no_tool_panics_when_the_viewer_sees_fewer_pages_than_the_file_has() { failures.push(e); } match def.name { - // The field sits on the sixth page; a rect is placed on the field's own page. + // The field sits on the sixth page; a rect is placed on the field's own page. That is + // refused, saying why, and the field is left as it was. "form_set_props" => { - if let Err(e) = guarded(&mut a, def.name, &json!({ "doc": doc, "field": "name", "rect": [10, 10, 60, 30] })) { - failures.push(e); + let fields = |a: &mut Automation| answer(a, "form_fields", &json!({ "doc": doc })); + let before = fields(&mut a); + match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &json!({ "doc": doc, "field": "name", "rect": [10, 10, 60, 30] })))) { + Ok(Err(ToolError::Failed(m))) if m == "name is on page 6, which could not be read" => {} + Ok(Err(e)) => failures.push(format!("form_set_props gave the wrong error: {e:?}")), + Ok(Ok(_)) => failures.push("form_set_props placed a rect on a page the viewer could not read".into()), + Err(_) => failures.push("form_set_props panicked on a rect for the field on page 6".into()), + } + let after = fields(&mut a); + if before.is_err() || before != after { + failures.push(format!("form_set_props changed the field: {before:?} became {after:?}")); } } // Listing still reports what is on the pages the viewer lacks, not just survives it. - "link_list" => { - let listed = catch_unwind(AssertUnwindSafe(|| a.call("link_list", &json!({ "doc": doc })))); - let count = match listed { - Ok(Ok(content)) => content.into_iter().find_map(|c| match c { - pdfcraft_automation::Content::Json(v) => v["count"].as_u64(), - pdfcraft_automation::Content::Png { .. } => None, - }), - _ => None, - }; - if count != Some(12) { - failures.push(format!("link_list listed {count:?} links, not the file's 12")); - } - } + "link_list" => match answer(&mut a, def.name, &json!({ "doc": doc })) { + Ok(v) if v["count"] == 12 => {} + other => failures.push(format!("link_list did not list the file's 12 links: {other:?}")), + }, + // The added text on page 6 is reported, its rect unchanged (user space), as + // `doc_info` reports rects on pages the viewer could not read. + "content_list" => match answer(&mut a, def.name, &json!({ "doc": doc })) { + Ok(v) + if v["items"].as_array().is_some_and(|items| { + items.iter().any(|it| it["page"] == 6 && it["type"] == "text" && it["rect"] == json!([70.0, 70.0, 150.0, 100.0])) + }) => {} + other => failures.push(format!("content_list did not report the added text on page 6 at [70, 70, 150, 100]: {other:?}")), + }, _ => {} } } @@ -445,5 +477,5 @@ fn no_tool_panics_when_the_viewer_sees_fewer_pages_than_the_file_has() { eprintln!(" {f}"); } let _ = std::fs::remove_dir_all(&root); - assert!(failures.is_empty(), "{} tool(s) panicked; the first is: {}", failures.len(), failures.first().map_or("", String::as_str)); + assert!(failures.is_empty(), "{} failure(s); the first is: {}", failures.len(), failures.first().map_or("", String::as_str)); } From d85d49a4c6b25aaa07b1b41b396544542088b7c6 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:17:50 +0800 Subject: [PATCH 19/31] M10: Export to Word keeps the page background colour (#2992) A page coloured with Edit a PDF > Background exported to Word as a white page: export pages carried text and images only, and the Word section has no page colour. - pdfcraft-edit `page_background`: the colour of the first thing a page paints, when that is an opaque fill (no soft mask, Normal blend) in a plain gray, RGB or CMYK colour, of a path with a rectangle covering the visible page, not clipped smaller; `None` otherwise. - `export::Page::background` carries it; the Word writer sets it as the document background (`w:background`, plus the `w:displayBackgroundShape` setting Word needs to show it) when every page shares one non-white colour. Word has one background per document, so differing colours are left out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/edit/src/background.rs | 175 ++++++++++++++++++++++++++++++++++ crates/edit/src/lib.rs | 2 + crates/edit/src/tests.rs | 55 +++++++++++ crates/edit/src/text.rs | 2 +- crates/engine/src/compare.rs | 4 +- crates/engine/src/tests.rs | 26 +++++ crates/export/src/lib.rs | 55 ++++++++++- crates/export/src/pptx.rs | 5 +- parity/acrobat-features.toml | 4 +- 9 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 crates/edit/src/background.rs diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs new file mode 100644 index 000000000..15fecd88e --- /dev/null +++ b/crates/edit/src/background.rs @@ -0,0 +1,175 @@ +//! A page's background colour: the colour the page is painted with before anything else, as +//! Edit a PDF ▸ Background and many producers do it, for exports that keep a page colour (#2992). + +use pdfcraft_content::{Matrix, Op, Pieces}; +use pdfcraft_cos::{Dict, Document}; + +use crate::{EditError, Rgb}; + +/// How deep `q` may nest while looking for the background (deeper saves are not tracked). +const MAX_SAVES: usize = 64; + +/// The colour space a fill colour is given in. +#[derive(Clone, Copy, PartialEq)] +enum Space { + Gray, + Rgb, + Cmyk, + /// Anything else (Separation, ICC, patterns…): no plain colour can be read from it. + Other, +} + +#[derive(Clone, Copy)] +struct State { + ctm: Matrix, + space: Space, + fill: Option, + /// Fill opacity (`ca`), and whether a soft mask or a blend mode makes the fill other than a + /// plain paint. + alpha: f64, + plain: bool, + /// The clip, as a rectangle in page space; `None` when a clip that isn't one rectangle was + /// set (its area can't be told). + clip: Option<[f64; 4]>, +} + +/// The page's background colour: the colour of the first thing its content paints, when that is +/// an opaque fill, in a gray, RGB or CMYK colour, of a path with a rectangle that covers the +/// whole visible page (and no clip keeps it from doing so). `None` for a page that paints anything else +/// first, or nothing. Forms and later content are not looked at. +pub fn page_background(doc: &Document, page: usize) -> Result, EditError> { + let pages = pdfcraft_model::pages(doc); + let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?; + 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 pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + + let mut st = State { ctm: Matrix::IDENTITY, space: Space::Gray, fill: Some([0.0; 3]), alpha: 1.0, plain: true, clip: Some(crop) }; + let mut saved: Vec = Vec::new(); + let mut lost_saves = 0usize; + // The path being built: its rectangles in page space, and whether it has anything else. + let mut rects: Vec<[f64; 4]> = Vec::new(); + let mut curved = false; + let mut clip_pending = false; + for op in &ops { + match op.op.as_slice() { + b"q" => { + if saved.len() < MAX_SAVES { + saved.push(st); + } else { + lost_saves = lost_saves.saturating_add(1); + } + } + b"Q" => { + if lost_saves > 0 { + lost_saves -= 1; + } else if let Some(s) = saved.pop() { + st = s; + } + } + b"cm" => { + if let Some(m) = Matrix::from_operands(&op.operands) { + st.ctm = m.then(&st.ctm); + } + } + b"g" => (st.space, st.fill) = (Space::Gray, op.nums::<1>().map(|[g]| [g; 3])), + b"rg" => (st.space, st.fill) = (Space::Rgb, op.nums::<3>()), + b"k" => (st.space, st.fill) = (Space::Cmyk, op.nums::<4>().map(cmyk)), + b"cs" => { + st.space = match op.name(0) { + Some(b"DeviceGray" | b"G") => Space::Gray, + Some(b"DeviceRGB" | b"RGB") => Space::Rgb, + Some(b"DeviceCMYK" | b"CMYK") => Space::Cmyk, + _ => Space::Other, + }; + st.fill = None; + } + b"sc" | b"scn" => st.fill = colour(st.space, op), + b"gs" => { + if let Some(g) = op.name(0).and_then(|n| gstates.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) { + apply_gstate(doc, &g, &mut st); + } + } + b"re" => match op.nums::<4>() { + Some([x, y, w, h]) => rects.push(bbox(&st.ctm, [x, y, x + w, y + h])), + None => curved = true, + }, + b"m" | b"l" | b"c" | b"v" | b"y" | b"h" => curved = true, + b"W" | b"W*" => clip_pending = true, + b"n" => { + if clip_pending { + st.clip = match (st.clip, rects.as_slice(), curved) { + (Some(c), [r], false) => Some(intersect(c, *r)), + _ => None, + }; + } + (rects, curved, clip_pending) = (Vec::new(), false, false); + } + b"f" | b"F" | b"f*" | b"B" | b"B*" => { + let opaque = st.plain && st.alpha >= 0.999; + let covers = !curved && st.clip.is_some_and(|c| covers(&c, &crop)) && rects.iter().any(|r| covers(r, &crop)); + return Ok(st.fill.filter(|_| opaque && covers && !clip_pending).map(|c| c.map(|v| v.clamp(0.0, 1.0)))); + } + // Anything else painted first: no page background. + b"S" | b"s" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\"" | b"Do" | b"sh" | b"BI" => return Ok(None), + _ => {} + } + } + Ok(None) +} + +/// CMYK to RGB, as a plain conversion without colour management. +fn cmyk([c, m, y, k]: [f64; 4]) -> Rgb { + [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)] +} + +/// The colour `sc`/`scn` sets in `space`, if it is a plain one. +fn colour(space: Space, op: &Op) -> Option { + match (space, op.operands.len()) { + (Space::Gray, 1) => op.nums::<1>().map(|[g]| [g; 3]), + (Space::Rgb, 3) => op.nums::<3>(), + (Space::Cmyk, 4) => op.nums::<4>().map(cmyk), + _ => None, + } +} + +/// The fill opacity, soft mask and blend mode an ExtGState sets. +fn apply_gstate(doc: &Document, g: &Dict, st: &mut State) { + if let Some(ca) = g.get(b"ca").and_then(|v| doc.resolve(v).as_f64()) { + st.alpha = if ca.is_finite() { ca } else { 0.0 }; + } + if let Some(mask) = g.get(b"SMask") { + st.plain &= doc.resolve(mask).as_name() == Some(b"None"); + } + if let Some(bm) = g.get(b"BM") { + let normal = |n: Option<&[u8]>| matches!(n, Some(b"Normal" | b"Compatible")); + st.plain &= match &*doc.resolve(bm) { + pdfcraft_cos::Object::Array(a) => a.first().is_some_and(|n| normal(doc.resolve(n).as_name())), + other => normal(other.as_name()), + }; + } +} + +/// The page-space bounding box of a user-space rectangle under `ctm`. +fn bbox(ctm: &Matrix, r: [f64; 4]) -> [f64; 4] { + let pts = [ctm.apply(r[0], r[1]), ctm.apply(r[2], r[1]), ctm.apply(r[0], r[3]), ctm.apply(r[2], r[3])]; + pts.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]) +} + +fn intersect(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { + [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])] +} + +/// Whether `outer` covers `page`, give or take half a percent of its size. +fn covers(outer: &[f64; 4], page: &[f64; 4]) -> bool { + let (w, h) = ((page[2] - page[0]).abs(), (page[3] - page[1]).abs()); + let (tx, ty) = (w * 0.005, h * 0.005); + outer.iter().all(|v| v.is_finite()) + && outer[0] <= page[0] + tx + && outer[1] <= page[1] + ty + && outer[2] >= page[2] - tx + && outer[3] >= page[3] - ty +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index c1062b410..f51fc75a0 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -847,6 +847,8 @@ pub fn marks_present(doc: &Document) -> Vec { added::scan_markers(doc).marks } +mod background; +pub use background::page_background; pub mod objects; pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects}; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 879ebc6c6..f151cd04b 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5218,3 +5218,58 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() { assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}"); assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched"); } + +/// #2992: the colour a page is painted with before anything else is its background, for exports +/// that keep a page colour. Edit a PDF ▸ Background's fill is found under the text it goes +/// behind; a page that paints anything else first, a fill that doesn't cover the page, a +/// translucent or clipped one, or a colour that isn't plain gray, RGB or CMYK has none. +#[test] +fn a_page_painted_first_with_a_full_page_fill_has_that_background() { + let mut doc = fixture(); + assert_eq!(page_background(&doc, 0).unwrap(), None, "text first"); + add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap(); + let doc = reopen(&doc); + let teal = page_background(&doc, 0).unwrap().expect("Background's fill"); + assert!(teal.iter().zip([0.0, 0.5, 0.5]).all(|(a, b)| (a - b).abs() < 1e-6), "{teal:?}"); + assert!(page_background(&doc, 1).unwrap().is_some(), "on a turned page too"); + assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content"); + assert!(page_background(&doc, 9).is_err()); + + let page = |content: &str, resources: &str| { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").as_bytes()); + page_background(&Document::open(Arc::new(out)).unwrap(), 0).unwrap() + }; + let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>"; + assert_eq!(page("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill"); + assert_eq!(page("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box"); + assert_eq!(page("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc"); + assert_eq!(page("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm"); + assert_eq!(page("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page"); + assert_eq!(page("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle"); + assert_eq!(page("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "one of the rectangles covers it"); + assert_eq!(page("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent"); + assert_eq!(page("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended"); + assert_eq!(page("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner"); + assert_eq!(page("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip"); + assert_eq!(page("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern"); + assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); + assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); + assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 267259217..f119f3307 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -525,7 +525,7 @@ struct Ts { render_mode: i64, } -fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { +pub(crate) fn content_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) { diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index f0ed2b4b8..50508e20e 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -228,7 +228,9 @@ impl crate::Document { }) }) .collect(); - pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images } + // The page colour (Edit a PDF ▸ Background, a producer's page fill) for formats that keep one. + let background = pdfcraft_edit::page_background(cos, i).ok().flatten(); + pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background } }) .collect(); (pages, truncated) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41da..3876f9b10 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6106,3 +6106,29 @@ fn undo_never_makes_an_unsaved_document_clean() { s.undo(new).unwrap(); assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean"); } + +/// #2992: a page coloured with Edit a PDF ▸ Background exports to Word with that page colour, +/// behind its text. +#[test] +fn exporting_to_word_keeps_the_page_background() { + let mut s = Session::new(); + let text = s.create_from_text("t", "Blue text on teal").unwrap(); + let id = s.open("t.pdf", None, text, None).unwrap(); + assert_eq!(s.get(id).unwrap().export_pages()[0].background, None, "no background yet"); + s.apply( + id, + Edit::AddBackground { + pages: vec![0], + settings: Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, + replace: false, + file: None, + }, + ) + .unwrap(); + let d = s.get(id).unwrap(); + let page = &d.export_pages()[0]; + assert!(page.background.is_some_and(|c| c.iter().zip([0.0, 0.5, 0.5]).all(|(a, b)| (a - b).abs() < 1e-6)), "{:?}", page.background); + assert!(page.blocks.iter().any(|b| b.text.contains("Blue text")), "the text is still there"); + let docx = d.export_office(compare::OfficeFormat::Docx); + assert!(docx.windows(b"word/settings.xml".len()).any(|w| w == b"word/settings.xml"), "Word is told to show the page colour"); +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 30299b2d7..453e33c2f 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -49,6 +49,9 @@ pub struct Page { pub height: f64, pub blocks: Vec, pub images: Vec, + /// The colour the whole page is painted before its content (a page background), as sRGB + /// components in 0..=1; `None` for a page with no background of its own. + pub background: Option<[f64; 3]>, } /// One cell of a detected table. `span` is how many grid columns the cell covers (a header @@ -662,6 +665,14 @@ fn section(page: &Page) -> String { ) } +/// The page colour of a Word document: Word has one background for the whole document, so it +/// is written only when every page has the same, non-white background (#2992). +fn word_background(pages: &[Page]) -> Option { + let first = rgb8(pages.first()?.background?); + let same = pages.iter().all(|p| p.background.map(rgb8) == Some(first)); + (same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2])) +} + /// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables; /// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout. pub fn docx(pages: &[Page], title: &str) -> Vec { @@ -728,10 +739,13 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { } after_table = false; } + // Word shows a document background only when its settings say so (`settings.xml` below). + let page_colour = word_background(pages); + let background = page_colour.as_deref().map(|c| format!("")).unwrap_or_default(); let doc = format!( "\n\ {body}\ +xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:w10=\"urn:schemas-microsoft-com:office:word\">{background}{body}\ " ); let mut types = String::from( @@ -742,6 +756,11 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin \ ", ); + if page_colour.is_some() { + types.push_str( + "", + ); + } types.push_str(""); let rels = "\n\ \ @@ -756,6 +775,9 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin i + 1 )); } + if page_colour.is_some() { + doc_rels.push_str(""); + } doc_rels.push_str(""); let styles = "\n\ \ @@ -773,6 +795,9 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin z.add("word/document.xml", doc.as_bytes(), true); z.add("word/styles.xml", styles.as_bytes(), true); z.add("word/_rels/document.xml.rels", doc_rels.as_bytes(), true); + if page_colour.is_some() { + z.add("word/settings.xml", b"\n", true); + } for (name, im) in &media { z.add(&format!("word/media/{name}"), &im.bytes, false); } @@ -912,6 +937,7 @@ mod tests { size: [200.0, 150.0], rotation: 0.0, }], + background: None, } } @@ -1008,6 +1034,30 @@ mod tests { } /// One part of a package written by [`Zip`], inflated. + /// #2992: pages that share a background colour get it as the Word document's page colour + /// (with the setting that makes Word show it); differing or white backgrounds don't. + #[test] + fn docx_keeps_a_shared_page_background() { + let teal = Page { background: Some([0.0, 0.5, 0.5]), ..page() }; + let zip = docx(&[teal.clone(), teal.clone()], "Teal"); + let xml = part(&zip, "word/document.xml"); + assert!(xml.contains(""), "{xml}"); + assert!(part(&zip, "word/settings.xml").contains("")); + assert!(part(&zip, "[Content_Types].xml").contains("/word/settings.xml")); + assert!(part(&zip, "word/_rels/document.xml.rels").contains("Target=\"settings.xml\"")); + let other = Page { background: Some([1.0, 0.0, 0.0]), ..page() }; + for (pages, why) in [ + (vec![teal.clone(), other], "different colours"), + (vec![teal.clone(), page()], "a page without one"), + (vec![Page { background: Some([1.0; 3]), ..page() }], "white"), + (vec![page()], "none"), + ] { + let zip = docx(&pages, "Plain"); + assert!(!part(&zip, "word/document.xml").contains(" String { let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); let mut i = 0; @@ -1134,6 +1184,7 @@ mod tests { }, ], images: Vec::new(), + background: None, } } @@ -1214,6 +1265,7 @@ mod tests { }) .collect(), images: Vec::new(), + background: None, }; let grid_cols = |p: &Page| part(&docx(std::slice::from_ref(p), "Grid"), "word/document.xml").matches(""), "tall two-column text must stay paragraphs: {h}"); diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index fc02c20c1..1a7250d9e 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -396,6 +396,7 @@ mod tests { size: [228.0, 150.0], rotation: 0.0, }], + background: None, } } @@ -522,6 +523,7 @@ mod tests { cell("100%", 200.0, 320.0), ], images: Vec::new(), + background: None, }; let bytes = pptx(&[sample_page(), table_page], "Two"); let parts = entries(&bytes); @@ -619,7 +621,7 @@ mod tests { let images = (0..40) .map(|k| Image { ext: "png", bytes: vec![0; 4], rect: [0.0, -1e300, 1e-300, 1e300 - k as f64], size: [1.0, 1.0], rotation: 0.0 }) .collect(); - let p = Page { width: f64::MAX, height: f64::NAN, blocks, images }; + let p = Page { width: f64::MAX, height: f64::NAN, blocks, images, background: None }; let bytes = pptx(&[p], "Hostile"); let slide = part(&bytes, "ppt/slides/slide1.xml"); assert!(slide.contains("Title") && slide.contains(""), "the slide is still written"); @@ -698,6 +700,7 @@ mod tests { cell("100%", 200.0, 320.0, 100.0, 12.0), ], images: Vec::new(), + background: None, }; let bytes = pptx(&[p], "Table Check"); let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8..5a4b9886d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6976,8 +6976,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["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"] -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, 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; 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." +evidence = ["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"] +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), 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; 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]] id = "create.export-rtf" From 8707e87affc161ad151506cc772e3d89f2e80c96 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:52:25 +0800 Subject: [PATCH 20/31] M10: give the #2806 export tests' pages a background after rebasing (#2992) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q --- crates/export/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 453e33c2f..27dbf7b4c 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -1300,6 +1300,7 @@ mod tests { block("conducted during a WGP tournament.", 164.3, 502.0, 352.5, 516.6), ], images: Vec::new(), + background: None, }; let h = html(std::slice::from_ref(&p), "Regulations"); assert!(!h.contains("
"), "running text became a table: {h}"); @@ -1319,7 +1320,7 @@ mod tests { blocks.push(block(lead, 72.0, y, 211.0, y + 12.0)); blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0)); } - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() }; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; let h = html(std::slice::from_ref(&p), "Event"); assert!(!h.contains("
"), "sentences became a table: {h}"); assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}"); @@ -1336,7 +1337,7 @@ mod tests { blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); } - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() }; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; let h = html(std::slice::from_ref(&p), "Prizes"); assert!(h.contains("
"), "the prize table was lost: {h}"); assert!(h.contains("30,000") && h.contains("15,000"), "{h}"); From ca0e0b08f3e5f4e56e5b410d25c095778bb4eb46 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:04:27 +0800 Subject: [PATCH 21/31] M10: a fill whose second rectangle cuts a hole is no page background (#2992) Review follow-ups on #2994: under even-odd any second rectangle, under nonzero one wound the other way, can leave a hole the Word page colour would cover. The parity note says the page colour hasn't been opened in Word yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q --- crates/edit/src/background.rs | 22 ++++++++++++++++++---- crates/edit/src/tests.rs | 6 ++++++ parity/acrobat-features.toml | 2 +- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs index 15fecd88e..2772d14ab 100644 --- a/crates/edit/src/background.rs +++ b/crates/edit/src/background.rs @@ -51,7 +51,9 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE let mut saved: Vec = Vec::new(); let mut lost_saves = 0usize; // The path being built: its rectangles in page space, and whether it has anything else. - let mut rects: Vec<[f64; 4]> = Vec::new(); + // Each rectangle of the current path: its box on the page, and whether it winds + // counterclockwise there (its width and height and the CTM's determinant agree in sign). + let mut rects: Vec<([f64; 4], bool)> = Vec::new(); let mut curved = false; let mut clip_pending = false; for op in &ops { @@ -94,7 +96,10 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE } } b"re" => match op.nums::<4>() { - Some([x, y, w, h]) => rects.push(bbox(&st.ctm, [x, y, x + w, y + h])), + Some([x, y, w, h]) => { + let [a, b, c, d, _, _] = st.ctm.0; + rects.push((bbox(&st.ctm, [x, y, x + w, y + h]), (w * h) * (a * d - b * c) > 0.0)); + } None => curved = true, }, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" => curved = true, @@ -102,7 +107,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE b"n" => { if clip_pending { st.clip = match (st.clip, rects.as_slice(), curved) { - (Some(c), [r], false) => Some(intersect(c, *r)), + (Some(c), [(r, _)], false) => Some(intersect(c, *r)), _ => None, }; } @@ -110,7 +115,16 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE } b"f" | b"F" | b"f*" | b"B" | b"B*" => { let opaque = st.plain && st.alpha >= 0.999; - let covers = !curved && st.clip.is_some_and(|c| covers(&c, &crop)) && rects.iter().any(|r| covers(r, &crop)); + // A second rectangle can cut a hole in the one that covers the page: under the + // even-odd rule any second one, under nonzero one wound the other way (a + // frame-style page border). + let even_odd = matches!(op.op.as_slice(), b"f*" | b"B*"); + let solid = match rects.as_slice() { + [_] => true, + [(_, first), rest @ ..] => !even_odd && rest.iter().all(|(_, ccw)| ccw == first), + [] => false, + }; + let covers = !curved && solid && st.clip.is_some_and(|c| covers(&c, &crop)) && rects.iter().any(|(r, _)| covers(r, &crop)); return Ok(st.fill.filter(|_| opaque && covers && !clip_pending).map(|c| c.map(|v| v.clamp(0.0, 1.0)))); } // Anything else painted first: no page background. diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f151cd04b..edf5d308b 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5264,6 +5264,12 @@ fn a_page_painted_first_with_a_full_page_fill_has_that_background() { assert_eq!(page("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page"); assert_eq!(page("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle"); assert_eq!(page("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "one of the rectangles covers it"); + // #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the + // other way under nonzero. + assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole"); + assert_eq!(page("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle"); + assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), Some([1.0, 0.0, 0.0]), "nonzero, same winding: no hole"); + assert_eq!(page("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), None, "reversed by a mirroring cm"); assert_eq!(page("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent"); assert_eq!(page("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended"); assert_eq!(page("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5a4b9886d..7c3c9d3d2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6977,7 +6977,7 @@ status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] evidence = ["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"] -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), 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; 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." +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; 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]] id = "create.export-rtf" From d5002807bedcb125b099675a00611421679b9d71 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:51:22 +0800 Subject: [PATCH 22/31] M10: a turned fill or clip covers the page only if its corners do (#2992) Review on #2994: fill rectangles and the clip were kept as their page-space bounding boxes, so a rectangle turned or sheared by the CTM could pass as covering the page while leaving its corners out, and a following fill became Word's page colour. Each rectangle is now kept as its four page-space corners, the clip as the rectangles it intersects, and the crop box counts as covered when its corners lie inside each. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q --- crates/edit/src/background.rs | 64 +++++++++++++++++++++-------------- crates/edit/src/tests.rs | 28 +++++++++++++++ 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs index 2772d14ab..d1d50e450 100644 --- a/crates/edit/src/background.rs +++ b/crates/edit/src/background.rs @@ -9,6 +9,13 @@ use crate::{EditError, Rgb}; /// How deep `q` may nest while looking for the background (deeper saves are not tracked). const MAX_SAVES: usize = 64; +/// How many clipping rectangles are kept; past that the clip counts as unknown. +const MAX_CLIPS: usize = 64; + +/// A rectangle as it lands on the page: its four corners in page space, in order. Under a +/// rotating or shearing CTM it is a parallelogram, so its bounding box says too much (#2994). +type Quad = [(f64, f64); 4]; + /// The colour space a fill colour is given in. #[derive(Clone, Copy, PartialEq)] enum Space { @@ -19,7 +26,7 @@ enum Space { Other, } -#[derive(Clone, Copy)] +#[derive(Clone)] struct State { ctm: Matrix, space: Space, @@ -28,9 +35,9 @@ struct State { /// plain paint. alpha: f64, plain: bool, - /// The clip, as a rectangle in page space; `None` when a clip that isn't one rectangle was - /// set (its area can't be told). - clip: Option<[f64; 4]>, + /// The clip, as the rectangles it is the intersection of, in page space; `None` when a clip + /// that isn't one rectangle was set (its area can't be told). + clip: Option>, } /// The page's background colour: the colour of the first thing its content paints, when that is @@ -47,20 +54,20 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - let mut st = State { ctm: Matrix::IDENTITY, space: Space::Gray, fill: Some([0.0; 3]), alpha: 1.0, plain: true, clip: Some(crop) }; + let mut st = State { ctm: Matrix::IDENTITY, space: Space::Gray, fill: Some([0.0; 3]), alpha: 1.0, plain: true, clip: Some(Vec::new()) }; let mut saved: Vec = Vec::new(); let mut lost_saves = 0usize; // The path being built: its rectangles in page space, and whether it has anything else. // Each rectangle of the current path: its box on the page, and whether it winds // counterclockwise there (its width and height and the CTM's determinant agree in sign). - let mut rects: Vec<([f64; 4], bool)> = Vec::new(); + let mut rects: Vec<(Quad, bool)> = Vec::new(); let mut curved = false; let mut clip_pending = false; for op in &ops { match op.op.as_slice() { b"q" => { if saved.len() < MAX_SAVES { - saved.push(st); + saved.push(st.clone()); } else { lost_saves = lost_saves.saturating_add(1); } @@ -98,7 +105,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE b"re" => match op.nums::<4>() { Some([x, y, w, h]) => { let [a, b, c, d, _, _] = st.ctm.0; - rects.push((bbox(&st.ctm, [x, y, x + w, y + h]), (w * h) * (a * d - b * c) > 0.0)); + rects.push((quad(&st.ctm, [x, y, x + w, y + h]), (w * h) * (a * d - b * c) > 0.0)); } None => curved = true, }, @@ -106,8 +113,11 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE b"W" | b"W*" => clip_pending = true, b"n" => { if clip_pending { - st.clip = match (st.clip, rects.as_slice(), curved) { - (Some(c), [(r, _)], false) => Some(intersect(c, *r)), + st.clip = match (st.clip.take(), rects.as_slice(), curved) { + (Some(mut c), [(r, _)], false) if c.len() < MAX_CLIPS => { + c.push(*r); + Some(c) + } _ => None, }; } @@ -124,7 +134,10 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE [(_, first), rest @ ..] => !even_odd && rest.iter().all(|(_, ccw)| ccw == first), [] => false, }; - let covers = !curved && solid && st.clip.is_some_and(|c| covers(&c, &crop)) && rects.iter().any(|(r, _)| covers(r, &crop)); + let covers = !curved + && solid + && st.clip.as_ref().is_some_and(|c| c.iter().all(|q| covers(q, &crop))) + && rects.iter().any(|(r, _)| covers(r, &crop)); return Ok(st.fill.filter(|_| opaque && covers && !clip_pending).map(|c| c.map(|v| v.clamp(0.0, 1.0)))); } // Anything else painted first: no page background. @@ -167,23 +180,22 @@ fn apply_gstate(doc: &Document, g: &Dict, st: &mut State) { } } -/// The page-space bounding box of a user-space rectangle under `ctm`. -fn bbox(ctm: &Matrix, r: [f64; 4]) -> [f64; 4] { - let pts = [ctm.apply(r[0], r[1]), ctm.apply(r[2], r[1]), ctm.apply(r[0], r[3]), ctm.apply(r[2], r[3])]; - pts.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]) +/// The page-space corners of a user-space rectangle under `ctm`, in order around it. +fn quad(ctm: &Matrix, r: [f64; 4]) -> Quad { + [ctm.apply(r[0], r[1]), ctm.apply(r[2], r[1]), ctm.apply(r[2], r[3]), ctm.apply(r[0], r[3])] } -fn intersect(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { - [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])] -} - -/// Whether `outer` covers `page`, give or take half a percent of its size. -fn covers(outer: &[f64; 4], page: &[f64; 4]) -> bool { +/// Whether `outer` covers `page`, give or take half a percent of its size: each corner of the +/// page, pulled in by that much, lies inside the parallelogram `outer`. +fn covers(outer: &Quad, page: &[f64; 4]) -> bool { let (w, h) = ((page[2] - page[0]).abs(), (page[3] - page[1]).abs()); let (tx, ty) = (w * 0.005, h * 0.005); - outer.iter().all(|v| v.is_finite()) - && outer[0] <= page[0] + tx - && outer[1] <= page[1] + ty - && outer[2] >= page[2] - tx - && outer[3] >= page[3] - ty + let (x0, y0, x1, y1) = (page[0].min(page[2]) + tx, page[1].min(page[3]) + ty, page[0].max(page[2]) - tx, page[1].max(page[3]) - ty); + let cross = |a: (f64, f64), b: (f64, f64), p: (f64, f64)| (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0); + // Twice the signed area: its sign says which way the corners run. + let area: f64 = (0..4).map(|i| cross((0.0, 0.0), outer[i], outer[(i + 1) % 4])).sum(); + if !area.is_finite() || area.abs() < f64::EPSILON || outer.iter().any(|p| !(p.0.is_finite() && p.1.is_finite())) { + return false; + } + [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].iter().all(|&p| (0..4).all(|i| cross(outer[i], outer[(i + 1) % 4], p) * area.signum() >= 0.0)) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index edf5d308b..be4e67d03 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5278,4 +5278,32 @@ fn a_page_painted_first_with_a_full_page_fill_has_that_background() { assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it"); + // #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop + // box's corners are inside it; its bounding box covering the crop box isn't enough. This one + // reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but + // 354 along the diagonals, short of the corners. One reaching 707 covers them. + let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm"; + assert_eq!(page(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), None, "a turned fill short of the corners"); + assert_eq!(page(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned fill over them"); + // The same for a clip. + assert_eq!(page(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), None, "a turned clip"); + assert_eq!( + page(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + Some([1.0, 0.0, 0.0]), + "a turned clip over the corners" + ); + // Clips add up: two that each cover the page leave it covered, one that doesn't among them + // doesn't. + assert_eq!(page("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "two covering clips"); + assert_eq!(page("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "the second clip doesn't cover"); + // Past 64 clipping rectangles the clip counts as unknown. + let clips = "0 0 600 800 re W n ".repeat(65); + assert_eq!(page(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow"); + // A rectangle drawn the other way round (negative width) covers the page all the same. + assert_eq!(page("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise"); + // The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the + // edge of that included. + assert_eq!(page("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance"); + assert_eq!(page("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge"); + assert_eq!(page("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance"); } From ab9a5009acabc1a8b8bfba8db69d3e1a8602a06a Mon Sep 17 00:00:00 2001 From: Ahmad Mujahid Date: Tue, 20 Oct 2026 13:23:20 +0400 Subject: [PATCH 23/31] Fix #3507: preserve content boundaries when merging image fragments --- ROADMAP.md | 4 + .../tests/optimize_content_boundaries.rs | 165 ++++++++++++++++++ crates/optimize/README.md | 3 +- crates/optimize/src/images.rs | 23 ++- crates/optimize/tests/optimize.rs | 26 +++ docs/roadmap.md | 4 +- parity/acrobat-features.toml | 2 +- 7 files changed, 212 insertions(+), 15 deletions(-) create mode 100644 crates/automation/tests/optimize_content_boundaries.rs diff --git a/ROADMAP.md b/ROADMAP.md index b1283e775..e64d8c6db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) @@ -172,6 +173,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-20 (M11, #3507):** Image merging keeps trailing comments, split operands and graphics state across content streams. Synthetic tool tests check saved text, rendered regions and exact image rows. + - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. - **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged. @@ -552,6 +555,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. | | 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | | 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. | | 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. | diff --git a/crates/automation/tests/optimize_content_boundaries.rs b/crates/automation/tests/optimize_content_boundaries.rs new file mode 100644 index 000000000..c37da05eb --- /dev/null +++ b/crates/automation/tests/optimize_content_boundaries.rs @@ -0,0 +1,165 @@ +//! Contributor-original PDFs exercise optimization across content stream boundaries. + +use std::sync::Arc; + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full}; +use serde_json::{Value, json}; + +const COLORS: [[u8; 3]; 4] = [[220, 30, 40], [20, 190, 60], [30, 60, 220], [230, 190, 20]]; + +fn fixture(ending: &str, split_operands: bool) -> Vec { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let font = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type1")), + (b"BaseFont".to_vec(), Object::name("Helvetica")), + ]))); + let mut xobjects = Dict::new(); + for (i, color) in COLORS.iter().enumerate() { + let dict = Dict::from_iter([ + (b"Type".to_vec(), Object::name("XObject")), + (b"Subtype".to_vec(), Object::name("Image")), + (b"Width".to_vec(), 20.into()), + (b"Height".to_vec(), 5.into()), + (b"BitsPerComponent".to_vec(), 8.into()), + (b"ColorSpace".to_vec(), Object::name("DeviceRGB")), + ]); + let image = doc.add(Object::Stream(Stream::flate(dict, &color.repeat(100)))); + xobjects.set(format!("Im{i}").into_bytes(), Object::Ref(image)); + } + // q and the translation stay active across both pieces. The optional split places + // the final two cm operands and its operator in the next stream. + let first = if split_operands { + "q 1 0 0 1".to_string() + } else { + format!("q 1 0 0 1 10 0 cm\nBT /F1 20 Tf 30 730 Td (FIRST) Tj ET\n% trailing comment{ending}") + }; + let mut second = if split_operands { + "10 0 cm\nBT /F1 20 Tf 30 730 Td (FIRST) Tj ET\n".to_string() + } else { + "BT /F1 20 Tf 30 400 Td (HIDDEN) Tj ET\n".to_string() + }; + for i in 0..4 { + second.push_str(&format!("q 20 0 0 5 100 {} cm /Im{i} Do Q\n", 200 + (3 - i) * 5)); + } + second.push_str("BT /F1 20 Tf 30 100 Td (LAST) Tj ET\nQ\n"); + let contents: Vec = + [first, second].into_iter().map(|piece| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), piece.into_bytes()))))).collect(); + let resources = Dict::from_iter([ + (b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"F1".to_vec(), Object::Ref(font))]))), + (b"XObject".to_vec(), Object::Dict(xobjects)), + ]); + let page = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Page")), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])), + (b"Resources".to_vec(), Object::Dict(resources)), + (b"Contents".to_vec(), Object::Array(contents)), + ]))); + doc.update_dict(pages, |dict| { + dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + dict.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +fn call(a: &mut Automation, tool: &str, args: Value) -> Value { + let result = a.call(tool, &args).unwrap(); + let Content::Json(value) = &result[0] else { panic!("expected JSON from {tool}") }; + value.clone() +} + +fn check_page(a: &mut Automation, doc: u64, hidden: bool, phase: &str) { + let text = call(a, "text_extract", json!({"doc": doc})); + let words: Vec<_> = text["pages"][0]["text"].as_str().unwrap().split_whitespace().collect(); + assert_eq!(words, if hidden { vec!["FIRST", "HIDDEN", "LAST"] } else { vec!["FIRST", "LAST"] }, "{phase} text"); + let rendered = a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap(); + let Content::Png { data, width, height } = &rendered[0] else { panic!("expected page PNG") }; + assert_eq!((*width, *height), (600, 800)); + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + let channels = info.color_type.samples(); + assert!(channels >= 3); + let pixel = |x: usize, y: usize| &pixels[(y * 600 + x) * channels..(y * 600 + x) * channels + 3]; + let dark = |left, top, right, bottom| { + (top..bottom).flat_map(|y| (left..right).map(move |x| (x, y))).filter(|&(x, y)| pixel(x, y).iter().any(|v| *v < 128)).count() + }; + assert!(dark(35, 45, 130, 75) > 30, "FIRST must draw at its translated position"); + assert!(dark(35, 675, 130, 705) > 30, "LAST must draw at its translated position"); + assert_eq!(dark(35, 375, 150, 405) > 0, hidden, "HIDDEN region must match the real comment ending"); + for (row, color) in COLORS.iter().enumerate() { + for y in 581 + row * 5..584 + row * 5 { + for x in 112..128 { + assert_eq!(pixel(x, y), color, "strip {row}, pixel {x},{y}"); + } + } + } + assert_eq!(pixel(105, 590), [255, 255, 255], "the cross-piece translation stays active"); +} + +fn check_saved_images(bytes: Vec, merged: bool) { + let doc = Document::open(Arc::new(bytes)).unwrap(); + let root = doc.get(doc.root().unwrap()); + let pages = doc.get(root.as_dict().unwrap().reference(b"Pages").unwrap()); + let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap(); + let page = doc.resolve(&kids[0]); + let resources = doc.resolve(page.as_dict().unwrap().get(b"Resources").unwrap()); + let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); + let images: Vec<_> = xobjects.as_dict().unwrap().iter().map(|(_, value)| doc.resolve(value)).collect(); + assert_eq!(images.len(), if merged { 1 } else { 4 }); + if merged { + let Object::Stream(image) = &*images[0] else { panic!("expected saved image") }; + assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(20), Some(20))); + let expected: Vec<_> = COLORS.iter().flat_map(|color| color.repeat(100)).collect(); + assert_eq!(image.decoded().unwrap(), expected, "saved RGB rows keep their strip order"); + } +} + +fn roundtrip(ending: &str, split_operands: bool, merge: bool) { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("input.pdf"), fixture(ending, split_operands)).unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + let doc = call(&mut a, "doc_open", json!({"path": "input.pdf"}))["doc"].as_u64().unwrap(); + let hidden = !ending.is_empty() && !split_operands; + check_page(&mut a, doc, hidden, "original"); + let report = call( + &mut a, + "doc_optimize", + json!({ + "doc": doc, "path": "output.pdf", "merge_fragments": merge, + "color": {"downsample": false, "compression": "retain"}, + "gray": {"downsample": false, "compression": "retain"} + }), + ); + assert_eq!(report["fragments_merged"], if merge { 4 } else { 0 }); + assert_eq!(report["merged_images"], if merge { 1 } else { 0 }); + let reopened = call(&mut a, "doc_open", json!({"path": "output.pdf"}))["doc"].as_u64().unwrap(); + check_page(&mut a, reopened, hidden, "reopened"); + check_saved_images(std::fs::read(dir.path().join("output.pdf")).unwrap(), merge); +} + +#[test] +fn fragment_merge_preserves_a_comment_across_content_pieces() { + roundtrip("", false, true); +} + +#[test] +fn fragment_merge_preserves_explicit_comment_line_endings() { + for ending in ["\n", "\r", "\r\n"] { + roundtrip(ending, false, true); + } +} + +#[test] +fn fragment_merge_preserves_split_operands_and_graphics_state() { + roundtrip("", true, true); +} + +#[test] +fn disabled_fragment_merge_preserves_content_pieces() { + roundtrip("", false, false); +} diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 32915af6b..3d517eef3 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -18,7 +18,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going drawn with nothing else between them), become one lossless image at their finest resolution, which the image settings then resample; the strips are dropped by Clean Up. A real page printed this way went from 4.3 MB to 231 KB (Standard) and 161 KB (Mobile). Not merged yet: - side-by-side tiles, rotated strips, masks. + side-by-side tiles, rotated strips, masks. Content arrays use the shared space boundary + policy, so trailing comments, split operands and graphics state keep their meaning after merging. - **Images:** the effective resolution of each image is measured where pages draw it (the CTM through form XObjects, up to 12 deep; the smallest over all uses). If a use can't be measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 8cfa82674..d5b237ca5 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet}; -use pdfcraft_content::Matrix; +use pdfcraft_content::{Matrix, Pieces}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; @@ -26,17 +26,16 @@ pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { 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) => a - .iter() - .flat_map(|c| match &*doc.resolve(c) { - Object::Stream(s) => { - let mut v = s.decoded().unwrap_or_default(); - v.push(b'\n'); - v - } - _ => Vec::new(), - }) - .collect(), + 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(), } } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2a83e8d6a..ecdda895e 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1022,3 +1022,29 @@ fn images_are_not_resampled_when_some_uses_could_not_be_measured() { 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"] { + let mut doc = Document::new_empty(); + let visible = image(&mut doc, 20, 20, 3, false, None); + let hidden = image(&mut doc, 20, 20, 3, false, None); + let page = page(&mut doc, &[("Visible", visible), ("Hidden", hidden)], ""); + let first = doc + .add(Object::Stream(Stream::from_raw(Dict::new(), format!("q 72 0 0 72 0 0 cm /Visible Do Q\n% trailing comment{ending}").into_bytes()))); + let second = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"q 144 0 0 144 0 0 cm /Hidden Do Q\n".to_vec()))); + doc.update_dict(page, |dict| { + dict.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(first), Object::Int(17), Object::Ref(second)])); + }) + .unwrap(); + let measured = effective_resolutions(&doc, &[page]); + assert_eq!(measured.get(&visible), Some(&20.0)); + if ending.is_empty() { + assert_eq!(measured.len(), 1); + assert_eq!(measured.get(&hidden), None, "a comment must hide the next piece's first line"); + } else { + assert_eq!(measured.len(), 2); + assert_eq!(measured.get(&hidden), Some(&10.0), "a real line ending exposes the image"); + } + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 5d73511e9..e777326e4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -146,7 +147,7 @@ total matches the parity estimate. | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | -| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | +| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). | @@ -200,6 +201,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. | | 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | | 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. | | 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8..64a2ae80d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7260,7 +7260,7 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["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/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"] 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]] From deca1e03d17b0673740332c59c9b4530dce1013a Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:26:03 +0300 Subject: [PATCH 24/31] Control channel: wait after a failed accept instead of retrying at once The control channel's accepting thread dropped failed accepts with incoming().flatten() and asked again at once. When accepting keeps failing, for example while the process has no file descriptors left, that loop keeps a core busy until it recovers. Wait 100 ms after a failed accept, as the single-instance listener already does. The loop goes through a small helper so the wait can be tested without running out of file descriptors. --- crates/ui-egui/src/control.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a4ab35d96..f9ada5735 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -916,6 +916,18 @@ pub fn serve(client: ControlClient) -> std::io::Result { serve_with(client, LIMITS) } +/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for +/// example while the process has no file descriptors left, and retrying at once would keep a +/// core busy until it recovers. The single-instance listener waits the same way. +#[cfg(not(target_arch = "wasm32"))] +const ACCEPT_RETRY: std::time::Duration = std::time::Duration::from_millis(100); + +/// The connections `incoming` accepts, calling `wait` after each failed accept. +#[cfg(not(target_arch = "wasm32"))] +fn accepted(incoming: impl Iterator>, mut wait: impl FnMut()) -> impl Iterator { + incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok()) +} + #[cfg(not(target_arch = "wasm32"))] fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result { use std::io::{BufReader, Write}; @@ -926,7 +938,7 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result std::io::Result { #[cfg(test)] mod transport_tests { + /// A failed accept is followed by a wait before the next one, so a listener that keeps + /// failing does not spin, and the connections around the failures still come through. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_failed_accept_waits_before_the_next() { + let failed = || Err(std::io::Error::other("no file descriptors left")); + let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)]; + let mut waits = 0; + let connections: Vec = super::accepted(incoming.into_iter(), || waits += 1).collect(); + assert_eq!(connections, [1, 2]); + assert_eq!(waits, 3, "one wait per failed accept"); + } + /// Before authenticating, a line that isn't JSON gets a parse error and closes the /// connection, as [`super::serve`] documents for any first request other than `auth`. #[cfg(not(target_arch = "wasm32"))] From 811a3a84c190a70ddab22b0b506c9b56d04a922f Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Tue, 20 Oct 2026 21:13:46 +0800 Subject: [PATCH 25/31] fix(xtask): wait for Chrome child processes to settle PDF after launcher exits (#3103) --- xtask/src/demo_pdf/chrome.rs | 143 ++++++++++++++++++++++++++++++++--- 1 file changed, 132 insertions(+), 11 deletions(-) diff --git a/xtask/src/demo_pdf/chrome.rs b/xtask/src/demo_pdf/chrome.rs index 7d4cbf91f..d1d098c2c 100644 --- a/xtask/src/demo_pdf/chrome.rs +++ b/xtask/src/demo_pdf/chrome.rs @@ -96,22 +96,18 @@ pub fn print_to_pdf(chrome: &Path, html: &Path, pdf: &Path, log: &Path) -> Resul let mut quiet_polls = 0; loop { if let Some(status) = child.try_wait()? { - if is_complete(pdf) { + // On Windows the launched `chrome.exe` can exit while its child processes are still + // writing the PDF, so give the file up to 60 s to appear, and only accept it once the + // size has been quiet and the file complete for 5 consecutive one-second polls. + if wait_for_settled_pdf(pdf, Duration::from_secs(60), Duration::from_millis(1000), 5) { return Ok(()); } bail!("Chrome exited with {status} without writing {}; see {}", pdf.display(), log.display()); } - let len = fs::metadata(pdf).map(|m| m.len()).ok(); - if len == last_len && len.is_some() && is_complete(pdf) { - quiet_polls += 1; - if quiet_polls >= 10 { - stop(&mut child); - return Ok(()); - } - } else { - quiet_polls = 0; + if poll_settled_pdf(pdf, &mut last_len, &mut quiet_polls, 10) { + stop(&mut child); + return Ok(()); } - last_len = len; if started.elapsed() > Duration::from_secs(120) { stop(&mut child); bail!("Chrome timed out printing {}; see {}", html.display(), log.display()); @@ -176,10 +172,58 @@ fn is_complete(pdf: &Path) -> bool { fs::read(pdf).map(|bytes| bytes.len() > 1024 && bytes[bytes.len() - 1024..].windows(5).any(|w| w == b"%%EOF")).unwrap_or(false) } +/// Check whether `pdf` is complete and unchanged since `last_len`, updating `last_len` and +/// `quiet_polls`. Returns `true` once `quiet_polls` reaches `required_quiet_polls`. +fn poll_settled_pdf(pdf: &Path, last_len: &mut Option, quiet_polls: &mut usize, required_quiet_polls: usize) -> bool { + let len = fs::metadata(pdf).map(|m| m.len()).ok(); + let settled = if len == *last_len && len.is_some() && is_complete(pdf) { + *quiet_polls += 1; + *quiet_polls >= required_quiet_polls.max(1) + } else { + *quiet_polls = 0; + false + }; + *last_len = len; + settled +} + +/// Poll `pdf` after the top-level Chrome process exits until the file exists, is complete, and +/// keeps the same byte length across `required_quiet_polls` consecutive polls of `poll_interval`, +/// or until `timeout` elapses. +fn wait_for_settled_pdf(pdf: &Path, timeout: Duration, poll_interval: Duration, required_quiet_polls: usize) -> bool { + let deadline = Instant::now() + timeout; + let mut last_len = None; + let mut quiet_polls = 0; + while Instant::now() < deadline { + if poll_settled_pdf(pdf, &mut last_len, &mut quiet_polls, required_quiet_polls) { + return true; + } + thread::sleep(poll_interval); + } + false +} + #[cfg(test)] mod tests { use super::*; + struct TestDir(PathBuf); + + impl TestDir { + fn new(tag: &str) -> Self { + let dir = env::temp_dir().join(format!("pdfcraft-chrome-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + #[test] fn args_disable_code_sign_clone_and_use_throwaway_profile() { let args = chrome_args(Path::new("/tmp/prof"), OsString::from("--print-to-pdf=out.pdf"), "file:///a.html"); @@ -196,4 +240,81 @@ mod tests { drop(profile); assert!(!dir.exists()); } + + #[test] + fn post_exit_wait_accepts_pdf_written_and_settled_after_launcher_exits() { + let dir = TestDir::new("exit-test"); + let pdf = dir.0.join("chrome.pdf"); + + let pdf_writer = pdf.clone(); + let writer = thread::spawn(move || { + thread::sleep(Duration::from_millis(20)); + // Partial write without `%%EOF` while child processes are still rendering. + let partial = vec![b'%'; 1500]; + fs::write(&pdf_writer, &partial).unwrap(); + + thread::sleep(Duration::from_millis(20)); + let mut complete = partial; + complete.extend_from_slice(&[b' '; 512]); + complete.extend_from_slice(b"\n%%EOF\n"); + fs::write(&pdf_writer, &complete).unwrap(); + complete.len() as u64 + }); + + assert!(wait_for_settled_pdf(&pdf, Duration::from_secs(5), Duration::from_millis(10), 5)); + let final_len = fs::metadata(&pdf).unwrap().len(); + let expected_len = writer.join().unwrap(); + assert_eq!(final_len, expected_len); + } + + #[test] + fn poll_settled_pdf_resets_quiet_count_when_incremental_eof_grows() { + let dir = TestDir::new("increment-test"); + let pdf = dir.0.join("chrome.pdf"); + + let mut last_len = None; + let mut quiet_polls = 0; + + // Missing file never settles. + assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5)); + assert_eq!(quiet_polls, 0); + + // First Skia increment already ends with `%%EOF`. + let mut first_increment = vec![b'%'; 1500]; + first_increment.extend_from_slice(b"\n%%EOF\n"); + fs::write(&pdf, &first_increment).unwrap(); + + // First observation records size; next 4 polls increment `quiet_polls` to 4 (< 5). + assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5)); + assert_eq!(quiet_polls, 0); + for expected_quiet in 1..=4 { + assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5)); + assert_eq!(quiet_polls, expected_quiet); + } + + // Second Skia increment grows the file before the 5th quiet poll: counter resets to 0. + let mut final_increment = first_increment; + final_increment.extend_from_slice(&[b' '; 1024]); + final_increment.extend_from_slice(b"\n%%EOF\n"); + fs::write(&pdf, &final_increment).unwrap(); + + assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5)); + assert_eq!(quiet_polls, 0); + + // Only after 5 consecutive unchanging polls at the new size does it report settled. + for expected_quiet in 1..=4 { + assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5)); + assert_eq!(quiet_polls, expected_quiet); + } + assert!(poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5)); + assert_eq!(quiet_polls, 5); + } + + #[test] + fn post_exit_wait_times_out_when_pdf_is_never_written() { + let dir = TestDir::new("timeout-test"); + let pdf = dir.0.join("missing.pdf"); + + assert!(!wait_for_settled_pdf(&pdf, Duration::from_millis(50), Duration::from_millis(10), 5)); + } } From ecaa2980ba1838d7ea85f989983238080fa439bc Mon Sep 17 00:00:00 2001 From: Nick Nyssen <314538108+amoisttowe1@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:20:36 -0700 Subject: [PATCH 26/31] crypt: say how the quoted vectors reproduce, and name what nothing verifies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FredApps' approval on #2359 carried one non-blocking nit that is a real defect: the header told contributors to "regenerate with make-oracle-fixtures.py in the workspace root" - a script that exists only in my checkout, next to a venv (.dc-tools/) likewise outside the repository. A contributor following it finds nothing, and this repository has no committed generator for these fixtures (crates/oracle treats qpdf strictly as an external process, and CLAUDE.md keeps the product and build in Rust). The header now carries a self-contained recipe, measured on this host rather than asserted: * one blank 200x200 page, pikepdf 9.11 / qpdf core 12.2.0, Encryption(owner=OWNER, user=USER, allow=Permissions(extract=False), R=, aes=…, metadata=…), save(static_id=True), then read /Encrypt. At revisions 2-3 `aes=False, metadata=False` are FORCED - pikepdf refuses AES ("Cannot encrypt with AES when R < 4") and refuses encrypted metadata ("Cannot encrypt metadata when R < 4") - so leaving those flags to the reader makes the export-grade row unreproducible. My first draft of this comment made exactly that mistake. * Reproduction scope, by revision: each of the four deterministic rows quotes eight dictionary fields (32 per comparison leg). Revisions 2 and 3 reproduce as whole files - fresh writes today carry the same sha256 (63f2d95b…, 39afbdd5…) as the October originals these literals came from. Revision 4 reproduces all eight fields, but its file moves by exactly one ~32-byte stream because /CFM /AESV2 prepends a fresh random IV (ISO 32000-2 §7.6.4); the dictionary and /ID are unchanged. Revision 6 reproduces structurally only (/ID, R/V/Length/P and the metadata flag); its salts are new each run. Running the generator at the reviewer's prompting exposed that it had not been executed since its last edit: unless both aes=False and metadata=False are forced pikepdf refuses R<4, checking metadata first, so it wrote 4 of the 6 fixtures - revisions 2 and 3 missing, including this file's headline export-grade row - while printing a "skipped:" line and exiting 0 regardless. It writes 6 of 6 now, and every quoted field reproduces as above. The overstatements flagged during review are corrected in this file; they are quoted verbatim, each with the basis for its new wording, in the PR description rather than here - commit messages cannot be edited after a push, descriptions can. Two further fixes live in this message alone: an ISO subsection written as §7.6.4.3, a form this repository does not cite (it uses bare §7.6.4 or the deeper §7.6.4.x.y), and one clause duplicated mid-cycle. What nothing here verifies, stated instead of implied - established by deliberately breaking the table (4 mutations: 2 red, 2 green): authentication never reads /Perms (a corrupted one fails only this file's own decrypt), and renaming a row's /StmF to Identity or replacing its crypt-filter set leaves all three tests green, so the filter names are transcription held to review. Pinning them, and re-deriving these rows through pdfcraft_oracle::qpdf() on the runner that installs qpdf, belong in a follow-up. Also recorded honestly: the revision-6 fixtures these rows were copied from are gone - my own regeneration overwrote them before I archived the folder - so those two rows rest on internal consistency plus this record, not a surviving artifact. Revisions 2 and 3 carry their own proof, byte for byte (sha256 63f2d95b… and 39afbdd5…, October and today); revision 4 reproduces every quoted field, with only the one IV-carrying stream moving. Scope: comments plus one assertion message (a revision 3 /U description that wrongly claimed qpdf leaves sixteen zero bytes; its condition, len == 64, is unchanged). No assertion logic, fixture, dependency or product code touched. Parent remains the approved cdbce8ac, so this lands as a fast-forward rather than a rewrite of the head FredApps reviewed. Gates at this tip (upstream/main 22bedbf2; branch 326 behind, trial merge clean): cargo fmt --all -- --check rc=0; clippy -p pdfcraft-crypt --all-targets -D warnings rc=0; cargo test -p pdfcraft-crypt --no-fail-fast 28 passed / 0 failed (17 lib + the 8 from #2358 plus these 3); cargo xtask layers OK, 37 crates; cargo xtask assets ok (272 repository, 6 bundled by dependencies, 22 fetched at build time). CI had not run on this tip when this was written - the commit was local. Co-authored-by: Qwen3.8-Flash-Next-oQ4e-WLSRefit-MTP Co-authored-by: DeepSeek V4.1 Flash --- crates/crypt/tests/qpdf_literals.rs | 55 ++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/crates/crypt/tests/qpdf_literals.rs b/crates/crypt/tests/qpdf_literals.rs index 01e341fd8..8957063ac 100644 --- a/crates/crypt/tests/qpdf_literals.rs +++ b/crates/crypt/tests/qpdf_literals.rs @@ -1,18 +1,38 @@ -//! Encryption numbers written by an **outside implementation**: qpdf core 12.2.0 (through pikepdf -//! 9.11) built each fixture, and the `/O`, `/U`, `/OE`, `/UE`, `/Perms` and `/ID` values quoted below -//! were copied out of those files rather than computed here. +//! Encryption numbers from an **outside implementation**. Six of the seven rows below are qpdf's own, +//! written by qpdf core 12.2.0 through pikepdf 9.11, and their `/O`, `/U`, `/OE`, `/UE`, `/Perms` +//! and `/ID` values were copied out of those files rather than computed here. The seventh row, +//! `crafted-r3-40bit-hand-derived`, is ours and says so where it is defined - no producer we can run +//! here writes revision 3 at 40 bits (qpdf core 12.2.0 emits `/Length 128` at `R=3`, measured below), +//! so nothing outside computed that one either. //! //! That distinction is the whole point. `create()` and `open()` share one derivation, so a wrong-but -//! self-consistent implementation passes every internal test: two review rounds proved it by breaking the -//! key derivation in ten different ways and watching both suites stay green. Quoting an outsider's bytes +//! self-consistent implementation round-trips cleanly against itself: across two review rounds we +//! broke the key derivation several ways and the unit suite stayed green (deleting Algorithm 2 step f +//! leaves all 17 unit tests passing while our rows fail). Quoting an outsider's bytes //! breaks the circle - `open()` recomputes `/U` (or the revision 6 hash chain) and compares it to what -//! qpdf stored, so a derivation that diverges cannot authenticate at all. +//! was stored, so a derivation that diverges cannot authenticate at all. //! //! Revisions covered: 2 (export-grade RC4, where the five-byte key makes a truncation error visible that -//! every existing R3/R4 vector in this repository hides as a no-op), 3, and 4 and 6 each with metadata -//! encrypted *and* plain - which is the only way to see Algorithm 2 step f's four `FF` bytes from -//! outside. Regenerate with `make-oracle-fixtures.py` in the workspace root (needs `.dc-tools/`, which -//! lives outside this repository); the PDFs themselves are never committed, only these quoted numbers. +//! every pre-existing R3/R4 vector in this repository hides as a no-op), 3, and 4 and 6 each with metadata +//! encrypted *and* plain. For revision 4 that pairing is the only outside view of Algorithm 2 step +//! f's four `FF` bytes; revision 6 shows the same flag differently - its `/U` differs between the two +//! metadata variants, which is measured here rather than attributed to a named algorithm step. Only the +//! numbers are committed - never the PDFs, and no generator script that lives outside +//! this repository. To re-derive the six qpdf rows, take one blank 200x200 page and save it with +//! pikepdf 9.11 (qpdf core 12.2.0) using `Encryption(owner=OWNER, user=USER,` +//! `allow=Permissions(extract=False), R=, aes=…, metadata=…)` plus `save(..., static_id=True)`, +//! then read `/Encrypt` back. Below revision 4 both flags are forced rather than chosen, because pikepdf +//! refuses AES and refuses encrypted metadata there - so those two rows need `aes=False, metadata=False`, +//! while revisions 4 and 6 take `aes=True` with the case's own metadata flag. Run to run, every quoted +//! field of those rows reproduces at revisions 2-4, and whole files too for revisions 2 and 3; a +//! revision 4 file falls short of that by one stream, because `/CFM /AESV2` prepends a fresh random IV. +//! Revision 6 reproduces structurally only, its salts being new each run. What stays checked rather than +//! trusted - `open()` recovers the user password from `/O` and verifies `/U` at revisions 2-4 +//! (Algorithm 7), matches both stored hashes at revision 6 and unwraps `/OE`/`/UE` into the file key +//! there; this file then decrypts `/Perms` with that key, which authentication itself never reads. One +//! thing no assertion here verifies is the crypt-filter names: renaming a row's `/StmF` or replacing its +//! filter set leaves all three tests green, so those are held to review rather than to a test. For how +//! this repository treats qpdf as an oracle, see `crates/oracle/README.md`. use pdfcraft_crypt::{Auth, EncryptDict, Method, SecurityHandler, aes_cbc_decrypt}; @@ -194,10 +214,10 @@ const CASES: &[Case] = &[ }, // HAND-DERIVED, not produced by qpdf: an independent re-implementation of ISO Algorithms 2/3 // (n = 5) computed this pair, cross-validated by having the same routine reproduce qpdf's - // n = 16 `/O` byte for byte. It exists because no conforming producer emits R3 at 40 bits - - // qpdf always writes 128 there, and this crate's own create() maps Rc4_40 to revision 2 - so - // the only way to reach `key_len` = 5 with the derivation loop RUNNING (r >= 3) is to craft - // it. With that combination live, the loop's truncation stops being a no-op: mutating + // n = 16 `/O` byte for byte. It exists because no producer we can run here emits R3 at 40 bits - + // qpdf core 12.2.0 writes `/Length 128` there, and this crate's own create() maps Rc4_40 to revision 2 - so + // the only route we have to `key_len` = 5 with the derivation loop RUNNING (r >= 3) is to craft + // it by hand. With that combination live, the loop's truncation stops being a no-op: mutating // `Md5::digest(&digest[..n])` to hash the whole digest makes this password stop authenticating. Case { label: "crafted-r3-40bit-hand-derived", @@ -257,8 +277,9 @@ fn outside_derived_dictionaries_authenticate_with_the_intended_role() { 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 - invisible to every internal test, because all - // of them build metadata-encrypted files. + // 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 + // 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"); let b = CASES.iter().find(|c| c.label == plain_label).expect("plain-metadata fixture present"); @@ -278,7 +299,7 @@ fn metadata_flag_from_outside_changes_the_file_key_it_certifies() { let r3 = CASES.iter().find(|c| c.r == 3).unwrap(); assert_ne!(hex(r2.u_hex), hex(r3.u_hex), "revision 2 and revision 3 /U differ because the key width differs"); assert_eq!(r2.u_hex.len(), 64, "revision 2 /U is RC4 over the padding: 32 bytes"); - assert_eq!(r3.u_hex.len(), 64, "revision 3 /U is sixteen bytes plus sixteen reserved zeros"); + assert_eq!(r3.u_hex.len(), 64, "revision 3 /U is 32 bytes: sixteen derived, plus padding qpdf fills (our crafted row leaves it zero)"); } #[test] From 87648be1cddf181df6344c3e597c61921adc1c16 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 22 Oct 2026 00:38:28 +0200 Subject: [PATCH 27/31] M14: JPEG and TIFF export refuse a raster of the wrong length instead of panicking encode_jpeg and encode_tiff composited the raster over white with as_chunks::<4>(), which drops a partial trailing pixel, and handed the result to encoders that trust its length. Run before this change: - JPEG: a partial pixel at the end was dropped and the rest written as a valid image; a buffer a pixel short, a pixel too many, empty, or with a size whose byte count overflows panicked (image's JpegEncoder checks the length with assert_eq!). - TIFF: a partial pixel or a pixel too many was written as a valid image; a buffer a pixel short panicked (tiff's write_data compares the buffer with the pixel count, not the sample count, then slices). The length check encode_png gained in #2425 is now check_raster, and all three encoders call it first. Rendered pages are always the right size, so what Export > Image writes is unchanged. export::raster_tests::rasters_of_the_wrong_length_are_rejected now runs its seven cases through PNG, JPEG and TIFF; it fails without the fix at "JPEG, opaque, a partial pixel after". rasters_of_no_pixels_are_an_error covers 0x0, 0x3 and 3x0, which are the right length and still errors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z --- ROADMAP.md | 1 + crates/engine/src/export.rs | 51 +++++++++++++++++++++++++----------- docs/roadmap.md | 2 ++ parity/acrobat-features.toml | 4 +-- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 49a8b27cc..6179fee2e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -220,6 +220,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged. - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. - **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged. +- **2026-10-22 (M14, JPEG and TIFF export):** Export ▸ Image as JPEG or TIFF refuses a raster that is not exactly width × height pixels, as PNG export has since #2425. A raster a pixel short panicked inside the JPEG and TIFF encoders, and a partial pixel at the end was dropped and the rest written as a valid image. Rendered pages are always the right size, so what exports is unchanged. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. - **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values. diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index a1820e2d9..d07531a8d 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -147,15 +147,7 @@ pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: Image /// (4.46 → 4.47 MB), a text page at 220 dpi 70 → 40 ms (0.85 → 0.89 MB), and an annual /// report page at 150 dpi 179 → 64 ms (1.41 → 1.38 MB). pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> { - // Checked here, not left to the encoder: `as_chunks` below drops a partial trailing pixel, - // and the opaque path would write the rest as a valid image. - let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4)); - if expected != Some(premultiplied.len()) { - return Err(match expected { - Some(n) => format!("a {width}×{height} raster is {n} bytes of RGBA, got {}", premultiplied.len()), - None => format!("a {width}×{height} raster is too large"), - }); - } + check_raster(width, height, premultiplied)?; let pixels = premultiplied.as_chunks::<4>().0; let opaque = pixels.iter().all(|px| px[3] == 255); let (color, data) = if opaque { @@ -213,6 +205,18 @@ impl ImageFormat { } } +/// Checks that `premultiplied` is exactly `width × height` RGBA pixels, before any encoder +/// sees it: `as_chunks` drops a partial trailing pixel, so the rest would be written as a valid +/// image, and the JPEG and TIFF encoders panic on a buffer that doesn't fit the size. +fn check_raster(width: u32, height: u32, premultiplied: &[u8]) -> Result<(), String> { + let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4)); + match expected { + Some(n) if n == premultiplied.len() => Ok(()), + Some(n) => Err(format!("a {width}×{height} raster is {n} bytes of RGBA, got {}", premultiplied.len())), + None => Err(format!("a {width}×{height} raster is too large")), + } +} + /// Premultiplied RGBA composited over white → RGB. fn over_white(premultiplied: &[u8]) -> Vec { premultiplied @@ -230,6 +234,7 @@ fn over_white(premultiplied: &[u8]) -> Vec { pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8, dpi: f64) -> Result, String> { use image::ImageEncoder; use image::codecs::jpeg::{JpegEncoder, PixelDensity}; + check_raster(width, height, premultiplied)?; let rgb = over_white(premultiplied); let mut out = Vec::new(); let mut enc = JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)); @@ -246,6 +251,7 @@ pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8], dpi: f64) -> R use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::RGB8}; use tiff::tags::{ResolutionUnit, Tag}; let err = |e: tiff::TiffError| e.to_string(); + check_raster(width, height, premultiplied)?; let rgb = over_white(premultiplied); let mut out = std::io::Cursor::new(Vec::new()); let mut tiff = TiffEncoder::new(&mut out).map_err(err)?.with_compression(Compression::Lzw); @@ -287,8 +293,10 @@ mod image_name_tests { } #[cfg(test)] -mod png_tests { - use super::encode_png; +mod raster_tests { + use super::{ImageFormat, encode_image, encode_png}; + + const FORMATS: [ImageFormat; 3] = [ImageFormat::Png, ImageFormat::Jpeg { quality: 90 }, ImageFormat::Tiff]; /// The colour type and pixels of a PNG. fn decode(png: &[u8]) -> (png::ColorType, Vec) { @@ -318,8 +326,9 @@ mod png_tests { assert_eq!(pixels, [128, 0, 64, 128, 0, 0, 0, 0, 10, 20, 30, 255]); } - /// A buffer that is not exactly `width × height` RGBA pixels is an error, opaque or not. The - /// opaque path used to drop a partial trailing pixel and write the rest as a valid image. + /// A buffer that is not exactly `width × height` RGBA pixels is an error in every format, + /// opaque or not. A partial trailing pixel used to be dropped and the rest written as a + /// valid image, and a buffer too short for its size panicked in the JPEG and TIFF encoders. #[test] fn rasters_of_the_wrong_length_are_rejected() { let cases: [(&str, u32, u32, &[u8]); 7] = [ @@ -331,8 +340,20 @@ mod png_tests { ("empty", 1, 1, &[]), ("a size whose byte count overflows", u32::MAX, u32::MAX, &[10, 20, 30, 255]), ]; - for (what, width, height, data) in cases { - assert!(encode_png(width, height, data).is_err(), "{what}"); + for format in FORMATS { + for (what, width, height, data) in cases { + assert!(encode_image(width, height, data, format, 72.0).is_err(), "{}, {what}", format.label()); + } + } + } + + /// A raster of no pixels is the right length, and still an error in every format. + #[test] + fn rasters_of_no_pixels_are_an_error() { + for format in FORMATS { + for (width, height) in [(0, 0), (0, 3), (3, 0)] { + assert!(encode_image(width, height, &[], format, 72.0).is_err(), "{}, {width}×{height}", format.label()); + } } } } diff --git a/docs/roadmap.md b/docs/roadmap.md index c10d91d20..03b6372f8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -21,6 +21,7 @@ > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M14: PNG export writes opaque pages as RGB at deflate level 3, the same size 3–5× faster; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14: JPEG and TIFF export refuse a raster of the wrong length instead of panicking; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -226,6 +227,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. | | 2026-10-12 | minor | M7 #1443: adding a word to a multi-line paragraph keeps its line breaks. Composite subset fonts whose `/W` leaves out default-width glyphs (Chrome, Skia) no longer measure those letters as missing, and paragraphs drawn turned on the page group into rows along their own baselines. Estimates unchanged. | | 2026-10-15 | minor | M14: PNG export (Export ▸ Image, `pdfcraft-cli render`, `page_render`) writes opaque pages as RGB at deflate level 3: the same file size, 3–5× faster to write. Estimates unchanged. | +| 2026-10-22 | minor | M14: JPEG and TIFF export (Export ▸ Image) refuse a raster that is not exactly width × height pixels, as PNG export does, instead of panicking in the encoder or dropping a partial pixel. Estimates unchanged. | | 2026-10-14 | minor | M14: Italian catalog completed (2,528 entries) and dropped from the ≈ 2,000–2,140-entry group; estimates unchanged | | 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. | | 2026-10-14 | minor | M5: reconciled PR #1457 with upstream main, preserving comment submission shortcuts and upstream roadmap progress. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 42bbe8352..3a83b86ab 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7052,8 +7052,8 @@ 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"] -notes = "JPEG with quality, TIFF; flattened onto white" +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"] +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]] id = "create.export-jpeg2000" From 071771f04023b17f139cd55f8b0ce7d6a2bd9620 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 22 Oct 2026 01:15:59 +0200 Subject: [PATCH 28/31] M14: check_raster refuses a raster of no pixels itself, before any encoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 0×0, 0×n or n×0 raster is the right length (no bytes) and already failed in all three encoders, but through each one's own error (PNG's "Zero width not allowed", the JPEG encoder's size check, tiff's dimension check). check_raster now refuses it first, with one message for every format, so the rule doesn't depend on the encoders' versions. rasters_of_no_pixels_are_an_error asserts that message for PNG, JPEG and TIFF; without the check it fails on PNG's own error. From CodeRabbit's review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z --- crates/engine/src/export.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index d07531a8d..01159df80 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -205,10 +205,14 @@ impl ImageFormat { } } -/// Checks that `premultiplied` is exactly `width × height` RGBA pixels, before any encoder -/// sees it: `as_chunks` drops a partial trailing pixel, so the rest would be written as a valid -/// image, and the JPEG and TIFF encoders panic on a buffer that doesn't fit the size. +/// Checks that `premultiplied` is exactly `width × height` RGBA pixels, and at least one, +/// before any encoder sees it: `as_chunks` drops a partial trailing pixel, so the rest would be +/// written as a valid image, and the JPEG and TIFF encoders panic on a buffer that doesn't fit +/// the size. fn check_raster(width: u32, height: u32, premultiplied: &[u8]) -> Result<(), String> { + if width == 0 || height == 0 { + return Err(format!("a {width}×{height} raster has no pixels")); + } let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4)); match expected { Some(n) if n == premultiplied.len() => Ok(()), @@ -347,12 +351,14 @@ mod raster_tests { } } - /// A raster of no pixels is the right length, and still an error in every format. + /// A raster of no pixels is the right length (no bytes), and refused before any encoder sees + /// it, in every format. #[test] fn rasters_of_no_pixels_are_an_error() { for format in FORMATS { for (width, height) in [(0, 0), (0, 3), (3, 0)] { - assert!(encode_image(width, height, &[], format, 72.0).is_err(), "{}, {width}×{height}", format.label()); + let refused = encode_image(width, height, &[], format, 72.0).err(); + assert_eq!(refused, Some(format!("a {width}×{height} raster has no pixels")), "{}", format.label()); } } } From 1b73c11e0de2f10ae97fed973ad265f9c7901744 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 01:45:34 +0000 Subject: [PATCH 29/31] Integration: #2994 put the part() doc comment back above part --- crates/export/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 27dbf7b4c..f3030ad58 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -1033,7 +1033,6 @@ mod tests { assert_eq!(xml.matches(">Meter").count(), 2); } - /// One part of a package written by [`Zip`], inflated. /// #2992: pages that share a background colour get it as the Word document's page colour /// (with the setting that makes Word show it); differing or white backgrounds don't. #[test] @@ -1058,6 +1057,7 @@ mod tests { } } + /// One part of a package written by [`Zip`], inflated. pub(crate) fn part(zip: &[u8], name: &str) -> String { let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); let mut i = 0; From eb6a4a2d79f7065c70add2ba30d80cc9d072c4ad Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 01:47:27 +0000 Subject: [PATCH 30/31] Integration: #1830 standards_remove_id is not the standards.pdfa command --- crates/automation/src/tools.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 73925ccb4..70f8bd6c8 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1090,7 +1090,6 @@ pub fn tools() -> Vec { .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])), t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.") - .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "standard": { "type": "string", "enum": ["pdfa", "pdfx"] } }), &["doc", "standard"])), t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).") .ro() From faa7e6acab517ba8c7eac66ffee32b433bfb95d3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 01:48:44 +0000 Subject: [PATCH 31/31] Integration: ATTRIBUTION hashes --- ATTRIBUTION.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index c5e55f063..2863bfd9b 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8fe572236493148b23270ef5766ad1c7d65a58b293757f34772d01a44a2df026" +sha256 = "8ab42885ae74f5afc633647396cddedf976461f89d2342a2ab1119ff72cf9dbf" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6ed886d00ad8cd39d8bf254f4733c8b8c90d5bf2c6868ff9c4ca42765a148786" +sha256 = "13a657709f3aac439c1abe9647424e69aa13bed994bc39629f55df7c24f98d64" [[asset]] @@ -3352,7 +3352,7 @@ 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 = "2c352743eaeec21eb9e522c71c43f3f6df34b81f024a5c72c85332e2ecc2b338" +sha256 = "707d8bed5b8dc6bd0329e44af1f72e4f62b586206cbc17916ce71c1a960563af" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv"