From b56ccad4fd8097bf4a5947a494a5181a69303c2a Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 12 Oct 2026 22:42:12 +0100 Subject: [PATCH 001/517] M10: recognize a scanned page when Edit is turned on for it Acrobat's Edit on a scanned page first converts it to editable text and images. In PdfCraft the words had to be recognized through Scan & OCR before Edit text & images could open them. Turning Edit text & images on for a page that is a picture (an image covering at least half of a page with no text) now runs the recognition for that page first: once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog. Pages with text, read-only files and other tools are left alone. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 3 ++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/ocr_ui.rs | 40 ++++++++++++++++++++++- crates/ui-egui/tests/ocr.rs | 61 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 6 files changed, 107 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 44a26604d8..d8494f382b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,6 +141,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-12 (M10, Edit on a scanned page):** Turning Edit text & images on for a scanned page (an image covering at least half of a page with no text) recognizes the page first, like Acrobat's "Converting scanned page to Editable Text and Images", so its words open for editing. Once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog (English only for now, gap 7). Pages with text, read-only files and other tools are left alone. The photographs on a scan are still part of the one page image. - **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. - **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged. - **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1f9105e213..a8b7ed542e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -373,6 +373,8 @@ pub struct DocView { zoom_anchor: Option<(usize, f32, f32, Vec2)>, /// The page view has been shown, so its scroll offset in egui's memory is this document's. shown: bool, + /// Pages that Edit already tried to turn into editable text (once each, so Undo sticks). + pub(crate) auto_ocr_tried: std::collections::HashSet, /// Turns wheel input into page turns in single-page view. wheel: crate::wheel_pager::WheelPager, /// Until this time, apply precise (touchpad) wheel input 1:1 and suppress egui's eased copy. @@ -625,6 +627,7 @@ impl DocView { viewport_screen: Rect::NOTHING, zoom_anchor: None, shown: false, + auto_ocr_tried: Default::default(), wheel: Default::default(), precise_scroll_until: 0.0, auto_scroll: Default::default(), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2d4f5b0993..be1f2e9005 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2524,6 +2524,7 @@ impl eframe::App for PdfCraftApp { } } self.poll_export(); + self.auto_ocr_scanned_page(); self.poll_ocr(); self.poll_optimize(); self.poll_action(); diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index a10cb0b0aa..68f525acc9 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -165,7 +165,45 @@ impl PdfCraftApp { OcrPages::Range => (d.from.saturating_sub(1)..d.to).collect(), }; let settings = d.settings(); - let Some(job) = self.session.ocr_job(id, &pages, settings) else { return }; + self.start_ocr_pages(id, &pages, settings); + } + + /// Edit on a scanned page: like Acrobat's "Converting scanned page to Editable Text and + /// Images", the page's words are recognized first, so that they open for editing (Edit text & + /// images groups the recognised words into phrases). Runs once per page, for the page in view + /// while the Edit tool is on, and only for a page that is a picture (an image covering at least + /// half of it, no text of its own: a page with text is skipped by the run). It is one undoable + /// step, "Recognize text", and Undo is not offered again for that page. + pub(crate) fn auto_ocr_scanned_page(&mut self) { + if self.quick_tool != crate::QuickTool::EditText || self.ocr_run.is_some() || self.ocr_batch.is_some() { + return; + } + let Some((vi, id)) = self.active_ids() else { return }; + let page = self.views[vi].current; + if self.views[vi].auto_ocr_tried.contains(&page) { + return; + } + let Some(doc) = self.session.get(id) else { return }; + if doc.read_only_reason.is_some() || !doc.allows_modification() { + return; + } + let Some(info) = doc.info.pages.get(page) else { return }; + let page_area = f64::from(info.width) * f64::from(info.height); + let covered = doc.page_images(page).iter().any(|i| { + let [x0, y0, x1, y1] = i.visible_rect.unwrap_or(i.rect); + page_area > 0.0 && ((x1 - x0) * (y1 - y0)).abs() >= 0.5 * page_area + }); + self.views[vi].auto_ocr_tried.insert(page); + if !covered || !pdfcraft_engine::ocr::available() { + return; + } + let settings = self.ocr_draft.settings(); + self.start_ocr_pages(id, &[page], settings); + } + + /// Recognize `pages` (0-based; empty = all) of document `id` in the background. + fn start_ocr_pages(&mut self, id: DocId, pages: &[usize], settings: OcrSettings) { + let Some(job) = self.session.ocr_job(id, pages, settings) else { return }; let progress = Arc::new(Mutex::new(OcrProgress { total: job.pages.len(), ..Default::default() })); let p = progress.clone(); let work = move || { diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index 20fb73344d..ee1256a498 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -162,3 +162,64 @@ fn an_ocr_word_opens_for_editing_and_stays_open() { h.run_steps(4); assert_eq!(opened(&h).as_deref(), Some("Recognize"), "moving the pointer away keeps the word open"); } + +/// A page of real text, not a picture. +fn text_page() -> Vec { + let s = Session::new(); + s.create_from_text("t", "This page already has its own text").unwrap().to_vec() +} + +fn edit_harness(pdf: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, pdf).unwrap(); + app.run_inline = true; + app + }); + h.run_steps(4); + h +} + +/// Like Acrobat's "Converting scanned page to Editable Text and Images": turning Edit on for a +/// scanned page recognizes its words first (one undoable "Recognize text" step), once. Undoing it +/// is not undone again by the next frame. +#[test] +fn editing_a_scanned_page_recognizes_its_text_first_and_only_once() { + if !pdfcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let mut h = edit_harness(scan()); + let id = h.state().active_ids().unwrap().1; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "nothing is recognized before Edit"); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + assert!(h.state().ocr_run.is_none(), "finished"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); + // Undo takes the text layer away and Edit does not put it back. + h.state_mut().undo(); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "recognized only once per page"); +} + +#[test] +fn editing_a_page_that_has_text_does_not_recognize_anything() { + let mut h = edit_harness(text_page()); + let id = h.state().active_ids().unwrap().1; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(6); + assert!(h.state().ocr_run.is_none()); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None); +} + +/// Only the Edit tool recognizes: viewing a scan, or any other tool, leaves the file as it is. +#[test] +fn only_the_edit_tool_recognizes_a_scanned_page() { + let mut h = edit_harness(scan()); + let id = h.state().active_ids().unwrap().1; + h.run_steps(6); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index eeba76118f..d99296efa2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6516,8 +6516,8 @@ milestone = "M10" status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/engine/tests/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open"] -notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Still missing: Acrobat's Recognize Text output that converts a whole page to editable text at once, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." +evidence = ["crates/engine/tests/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] +notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text, once per page per window; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." [[feature]] id = "ocr.ocr-downsample" From f9c1ddb5f9bf05f4c16c55e888b12d66b0a8c056 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 12 Oct 2026 23:27:04 +0100 Subject: [PATCH 002/517] M10: recognize a scanned page when the Edit tab is opened, not only the Edit text tool The Edit tab opens the Edit panel without picking Edit text & images, so the recognition added in the previous commit did not run for people who just clicked Edit. It now runs whenever Edit is on by the page view's own test: the Edit panel is open, or Edit text & images or Add text is the tool. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ocr_ui.rs | 8 ++++++-- crates/ui-egui/tests/ocr.rs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 68f525acc9..27778957c9 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -171,11 +171,15 @@ impl PdfCraftApp { /// Edit on a scanned page: like Acrobat's "Converting scanned page to Editable Text and /// Images", the page's words are recognized first, so that they open for editing (Edit text & /// images groups the recognised words into phrases). Runs once per page, for the page in view - /// while the Edit tool is on, and only for a page that is a picture (an image covering at least + /// while Edit is on (its tab, or the Edit text & images or Add text tool), and only for a page that is a picture (an image covering at least /// half of it, no text of its own: a page with text is skipped by the run). It is one undoable /// step, "Recognize text", and Undo is not offered again for that page. pub(crate) fn auto_ocr_scanned_page(&mut self) { - if self.quick_tool != crate::QuickTool::EditText || self.ocr_run.is_some() || self.ocr_batch.is_some() { + // Edit is on when its panel is open (the Edit tab) or Edit text & images or Add text is picked, + // the same test the page view uses for editing content. + let editing = (self.left_open && self.left == crate::LeftPanel::Tool("edit")) + || matches!(self.quick_tool, crate::QuickTool::EditText | crate::QuickTool::AddText); + if !editing || self.ocr_run.is_some() || self.ocr_batch.is_some() { return; } let Some((vi, id)) = self.active_ids() else { return }; diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index ee1256a498..415b3eea27 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -205,6 +205,23 @@ fn editing_a_scanned_page_recognizes_its_text_first_and_only_once() { assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "recognized only once per page"); } +/// The Edit tab (its tool panel) turns Edit on without picking Edit text & images, and Acrobat's +/// conversion happens there too. +#[test] +fn the_edit_panel_recognizes_a_scanned_page_too() { + if !pdfcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let mut h = edit_harness(scan()); + let id = h.state().active_ids().unwrap().1; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None); + h.state_mut().set_option("tool", "edit").unwrap(); + h.run_steps(4); + assert!(h.state().ocr_run.is_none(), "finished"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); +} + #[test] fn editing_a_page_that_has_text_does_not_recognize_anything() { let mut h = edit_harness(text_page()); From 21df6fc30ee3b859ca06c8ccefd5bb48d9505766 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 12 Oct 2026 23:30:34 +0100 Subject: [PATCH 003/517] M10: Edit recognizes a scan that also carries some text of its own Pages with a comment or text box over a scanned picture count as having text, so the recognition run skipped them ("the page already contains text") and Edit did nothing for them. The run started by Edit now also reads the picture of such a page, as Acrobat does, and the words the page's text already has are not added twice. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ocr_ui.rs | 5 ++++- crates/ui-egui/tests/ocr.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 27778957c9..3549753f74 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -201,7 +201,10 @@ impl PdfCraftApp { if !covered || !pdfcraft_engine::ocr::available() { return; } - let settings = self.ocr_draft.settings(); + // A picture on a page that has some text of its own (a comment, a text box) is still a + // scan, as in Acrobat; the words the page's text already has are not added twice. + let mut settings = self.ocr_draft.settings(); + settings.skip_text_pages = false; self.start_ocr_pages(id, &[page], settings); } diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index 415b3eea27..b53623b6b6 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -222,6 +222,38 @@ fn the_edit_panel_recognizes_a_scanned_page_too() { assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); } +/// A scanned page that also carries some text of its own (a comment or text box over the map) is +/// still a scan: Edit reads the picture, and the page's own words are not added a second time. +#[test] +fn editing_a_scan_that_carries_some_text_still_recognizes_the_picture_once() { + if !pdfcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let hybrid = { + let mut s = Session::new(); + let id = s.open("scan.pdf", None, std::sync::Arc::new(scan()), None).unwrap(); + let (w, h) = { + let p = &s.get(id).unwrap().info.pages[0]; + (p.width as f64, p.height as f64) + }; + let note = + AddedText { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], text: "Invoice reference 4711".into(), size: 18.0, ..Default::default() }; + s.apply(id, Edit::AddText { page: 0, text: note }).unwrap(); + s.save_bytes(id).unwrap().to_vec() + }; + let mut h = edit_harness(hybrid); + let id = h.state().active_ids().unwrap().1; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); + let doc = h.state().session.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); + let text = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + let text = text.text.unwrap().plain_text().to_lowercase(); + assert_eq!((text.matches("invoice").count(), text.matches("scanned").count()), (1, 1), "{text}"); +} + #[test] fn editing_a_page_that_has_text_does_not_recognize_anything() { let mut h = edit_harness(text_page()); From 2f761c852d361ad6b7c5da99ede3fd166d7cd883 Mon Sep 17 00:00:00 2001 From: Philip Date: Tue, 13 Oct 2026 09:39:57 +0100 Subject: [PATCH 004/517] M10: Edit reads only a page with no text of its own Review of #1609: the Edit-triggered recognition also read pages that have real text (a flyer or slide export with a full-page background picture), adding an undo step and marking the file modified just because Edit was turned on. It now runs with skip_text_pages on, so such a page is left alone. A new OcrSettings::native_text_only decides what counts as the page's text: its own content and form fields, not comments and text boxes (which are also not drawn into the picture that is read), so a scan with a few comments on it is still a scan, as in Acrobat. The doc comment now says what the code does. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- crates/engine/src/ocr.rs | 10 +++++-- crates/engine/src/tests.rs | 56 ++++++++++++++++++++++++++++++++++++ crates/ui-egui/src/ocr_ui.rs | 18 +++++++----- crates/ui-egui/tests/ocr.rs | 41 +++++++++++++++++++------- parity/acrobat-features.toml | 2 +- 6 files changed, 108 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e4fe4a221e..40d7322d93 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,7 +148,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log -- **2026-10-12 (M10, Edit on a scanned page):** Turning Edit text & images on for a scanned page (an image covering at least half of a page with no text) recognizes the page first, like Acrobat's "Converting scanned page to Editable Text and Images", so its words open for editing. Once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog (English only for now, gap 7). Pages with text, read-only files and other tools are left alone. The photographs on a scan are still part of the one page image. +- **2026-10-12 (M10, Edit on a scanned page):** Turning Edit on (its tab, Edit text & images or Add text) for a scanned page (an image covering at least half of a page with no text of its own; comments and text boxes over it do not count) recognizes the page first, like Acrobat's "Converting scanned page to Editable Text and Images", so its words open for editing. Once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog (English only for now, gap 7). Pages with real text (a flyer with a background picture), read-only files and other tools are left alone. The photographs on a scan are still part of the one page image. - **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/engine/src/ocr.rs b/crates/engine/src/ocr.rs index 6bf6dc9c75..1972a42710 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -24,6 +24,11 @@ pub struct OcrSettings { /// text" and skips them). When `false`, such pages are read too (a picture on a page that /// also has text, #1028) and only the words that aren't already there are added. pub skip_text_pages: bool, + /// What counts as the page's text when `skip_text_pages` is on: only its own (page content + /// and form fields). Comments and text boxes are left out, and are not drawn into the picture + /// that is read, so a scan with a few comments on it is still a scan. `false`: everything + /// the page shows. + pub native_text_only: bool, } /// A recognised word whose box is at least this much covered by the page's own text is that @@ -47,7 +52,7 @@ fn covered(word: [f32; 4], text: &[[f32; 4]]) -> f32 { impl Default for OcrSettings { fn default() -> Self { - OcrSettings { dpi: 300.0, language: "en".into(), skip_text_pages: true } + OcrSettings { dpi: 300.0, language: "en".into(), skip_text_pages: true, native_text_only: false } } } @@ -101,7 +106,8 @@ impl OcrJob { /// Read the pages. `progress(done, total)` is called before each page; returning `false` /// stops (the pages read so far are returned). pub fn run(self, ocr: &Ocr, mut progress: impl FnMut(usize, usize) -> bool) -> Vec { - let config = RenderConfig { password: self.password.as_deref().map(Arc::from), ..Default::default() }; + let config = + RenderConfig { password: self.password.as_deref().map(Arc::from), hide_comments: self.settings.native_text_only, ..Default::default() }; let mut r = PageRenderer::new(self.bytes.clone(), config); let mut out = Vec::new(); let total = self.pages.len(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 37cafd440a..25b1eb076b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2670,6 +2670,62 @@ fn recognize_text_reads_a_picture_on_a_page_that_has_text() { assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 1)); } +/// Edit reads a scan that only has comments on it, not a page with real text and a picture +/// (`native_text_only`): a text box over the picture is not the page's text, page content is. +#[test] +fn native_text_only_ignores_comments_but_not_page_text() { + if !ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let mut s = Session::new().with_clock(|| 1_700_000_000); + let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap(); + let id = s.open("text.pdf", None, text, None).unwrap(); + let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap(); + let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap(); + let comment_only = |s: &mut Session| { + let id = s.open("scan.pdf", None, scan.clone(), None).unwrap(); + let (w, h) = { + let p = &s.get(id).unwrap().info.pages[0]; + (p.width as f64, p.height as f64) + }; + let shape = pdfcraft_annot::Shape::TextBox { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], font_size: 18.0 }; + s.apply( + id, + Edit::AddAnnotation(NewAnnotation { + page: 0, + style: pdfcraft_annot::Style::default_for(&shape), + shape, + contents: "Invoice reference 4711".into(), + author: "Test".into(), + }), + ) + .unwrap(); + id + }; + let native = ocr::OcrSettings { native_text_only: true, ..Default::default() }; + + // A comment over the picture: the default run counts it as text and skips the page, the + // native-text run reads the picture. + let id = comment_only(&mut s); + let skipped = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); + assert!(skipped[0].skipped.is_some(), "a comment is text for the default run"); + let found = s.recognize_text(id, &[], native.clone()).unwrap(); + assert_eq!(found[0].skipped, None, "{:?}", found[0]); + assert!(found[0].text().to_lowercase().contains("brown"), "{:?}", found[0]); + + // Page content over the picture is the page's own text: skipped, whatever the setting. + let id = comment_only(&mut s); + let (w, h) = { + let p = &s.get(id).unwrap().info.pages[0]; + (p.width as f64, p.height as f64) + }; + let note = AddedText { rect: [w * 0.1, h * 0.6, w * 0.9, h * 0.7], text: "Page text".into(), size: 18.0, ..Default::default() }; + s.apply(id, Edit::AddText { page: 0, text: note }).unwrap(); + let skipped = s.recognize_text(id, &[], native).unwrap(); + assert!(skipped[0].skipped.is_some(), "{:?}", skipped[0]); +} + /// A form whose scripts are custom JavaScript: total = price × qty (calculate, through a /// document-level function), shown with a custom format; qty is validated; a button script. fn scripted_form() -> Vec { diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 3549753f74..64d44e714a 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -41,7 +41,7 @@ impl Default for OcrDraft { impl OcrDraft { /// The recognition settings these choices make. pub fn settings(&self) -> OcrSettings { - OcrSettings { dpi: self.dpi as f32, language: self.language.clone(), skip_text_pages: !self.text_pages } + OcrSettings { dpi: self.dpi as f32, language: self.language.clone(), skip_text_pages: !self.text_pages, ..Default::default() } } } @@ -171,9 +171,11 @@ impl PdfCraftApp { /// Edit on a scanned page: like Acrobat's "Converting scanned page to Editable Text and /// Images", the page's words are recognized first, so that they open for editing (Edit text & /// images groups the recognised words into phrases). Runs once per page, for the page in view - /// while Edit is on (its tab, or the Edit text & images or Add text tool), and only for a page that is a picture (an image covering at least - /// half of it, no text of its own: a page with text is skipped by the run). It is one undoable - /// step, "Recognize text", and Undo is not offered again for that page. + /// while Edit is on (its tab, or the Edit text & images or Add text tool). The page must be a + /// picture (an image covering at least half of it) with no text of its own: comments and text + /// boxes over it do not count as text, but real page text does, and such a page (a flyer with a + /// background picture) is left alone. It is one undoable step, "Recognize text", and is not + /// run again for that page after Undo. pub(crate) fn auto_ocr_scanned_page(&mut self) { // Edit is on when its panel is open (the Edit tab) or Edit text & images or Add text is picked, // the same test the page view uses for editing content. @@ -201,10 +203,12 @@ impl PdfCraftApp { if !covered || !pdfcraft_engine::ocr::available() { return; } - // A picture on a page that has some text of its own (a comment, a text box) is still a - // scan, as in Acrobat; the words the page's text already has are not added twice. + // Only a page with no text of its own is read. Comments and text boxes over the picture do + // not count as its text (a scan with a few comments is still a scan, as in Acrobat), but + // a page with real text and a background picture is left alone. let mut settings = self.ocr_draft.settings(); - settings.skip_text_pages = false; + settings.skip_text_pages = true; + settings.native_text_only = true; self.start_ocr_pages(id, &[page], settings); } diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index b53623b6b6..906551554d 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -222,14 +222,38 @@ fn the_edit_panel_recognizes_a_scanned_page_too() { assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); } -/// A scanned page that also carries some text of its own (a comment or text box over the map) is -/// still a scan: Edit reads the picture, and the page's own words are not added a second time. +/// A scan with only a comment (a text box) over it is still a scan: Edit reads the picture. The +/// comment's own words are not read into the layer, and it counts as no page text. #[test] -fn editing_a_scan_that_carries_some_text_still_recognizes_the_picture_once() { +fn editing_a_scan_with_a_comment_over_it_recognizes_the_picture() { if !pdfcraft_engine::ocr::available() { eprintln!("skipped: OCR models not installed"); return; } + let with_comment = { + let mut s = Session::new(); + let id = s.open("scan.pdf", None, std::sync::Arc::new(scan()), None).unwrap(); + let (w, h) = { + let p = &s.get(id).unwrap().info.pages[0]; + (p.width as f64, p.height as f64) + }; + let shape = pdfcraft_engine::Shape::TextBox { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], font_size: 18.0 }; + let style = pdfcraft_engine::Style::default_for(&shape); + let note = pdfcraft_engine::NewAnnotation { page: 0, shape, style, contents: "Invoice reference 4711".into(), author: "Test".into() }; + s.apply(id, Edit::AddAnnotation(note)).unwrap(); + s.save_bytes(id).unwrap().to_vec() + }; + let mut h = edit_harness(with_comment); + let id = h.state().active_ids().unwrap().1; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); +} + +/// A born-digital page with a full-page background picture and real text (a flyer, a slide +/// export) must not change just because Edit is turned on (maintainer review of #1609). +#[test] +fn editing_a_page_with_text_over_a_background_picture_changes_nothing() { let hybrid = { let mut s = Session::new(); let id = s.open("scan.pdf", None, std::sync::Arc::new(scan()), None).unwrap(); @@ -245,13 +269,10 @@ fn editing_a_scan_that_carries_some_text_still_recognizes_the_picture_once() { let mut h = edit_harness(hybrid); let id = h.state().active_ids().unwrap().1; assert!(h.state_mut().execute("edit.edit_text")); - h.run_steps(4); - assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); - let doc = h.state().session.get(id).unwrap(); - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); - let text = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); - let text = text.text.unwrap().plain_text().to_lowercase(); - assert_eq!((text.matches("invoice").count(), text.matches("scanned").count()), (1, 1), "{text}"); + h.run_steps(6); + assert!(h.state().ocr_run.is_none()); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "nothing was recognized or marked modified"); + assert!(!h.state().session.get(id).unwrap().dirty, "the file is not marked modified"); } #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ff9f2b017f..17fa5103bc 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6517,7 +6517,7 @@ status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] evidence = ["crates/engine/tests/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] -notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text, once per page per window; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." +notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." [[feature]] id = "ocr.ocr-downsample" From f3b6864bccb1e8a2621fc954357ea5c0cc65174c Mon Sep 17 00:00:00 2001 From: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:08:39 -0400 Subject: [PATCH 005/517] Attachments: attach a file and delete one (panel, edit, tools); duplicate names numbered as text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pdfcraft_organize::add_attachment puts a file in /Names /EmbeddedFiles as a Filespec with a Flate-compressed EmbeddedFile stream (/Subtype, /Size, /ModDate), the name made unique; delete_attachment removes one, the last taking the tree with it. Edit::AddAttachment / Edit::DeleteAttachment (undoable), Edit ▸ Attach a file… with a button at the top of the Attachments panel and a trash button on each document-level row, tools attachment_add and attachment_delete, rows in every language, parity attachments-add and attachments-delete shipped. Fixes set_attachments (Combine): a duplicate name was numbered by appending ASCII bytes, corrupting UTF-16 keys; names are now numbered as text. An indirect /Names is updated in place instead of replaced, so its /Dests and /JavaScript stay. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/lib.rs | 18 +++ crates/automation/src/tools.rs | 6 + crates/automation/tests/automation.rs | 40 +++++++ crates/engine/src/catalog.rs | 2 +- crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 24 +++- crates/organize/src/attachments.rs | 159 ++++++++++++++++++++++++++ crates/organize/src/import.rs | 24 +++- crates/organize/src/lib.rs | 2 + crates/organize/src/tests.rs | 81 +++++++++++++ crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/i18n/ar.tsv | 5 + crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/kk.tsv | 5 + crates/ui-egui/src/i18n/lt.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sl.tsv | 5 + crates/ui-egui/src/i18n/te.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/lib.rs | 32 ++++++ crates/ui-egui/src/panels.rs | 14 +++ parity/acrobat-features.toml | 18 +-- 35 files changed, 511 insertions(+), 16 deletions(-) create mode 100644 crates/organize/src/attachments.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index d7eb8a2baa..aff732462f 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -703,6 +703,24 @@ impl Automation { "content_update" => self.content_update(&a)?, "content_delete" => self.content_delete(&a)?, "doc_print" => self.doc_print(&a)?, + "attachment_add" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let name = match a.opt_str("name")? { + Some(n) => n.to_string(), + None => path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + }; + let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string); + let description = a.opt_str("description")?.map(str::to_string); + let before: Vec = self.doc(&a)?.info.attachments.iter().map(|x| x.name.clone()).collect(); + let out = self.apply(&a, Edit::AddAttachment { name, bytes, description, mime })?; + let key = self.doc(&a)?.info.attachments.iter().map(|x| x.name.clone()).find(|n| !before.contains(n)).unwrap_or_default(); + json!({ "name": key, "document": out }) + } + "attachment_delete" => { + let edit = Edit::DeleteAttachment { key: a.str("name")?.into() }; + self.apply(&a, edit)? + } "doc_remove_hidden" => self.doc_remove_hidden(&a)?, "doc_audit_redactions" => self.doc_audit_redactions(&a)?, "fill_sign_add" => self.fill_sign_add(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index cfdeb1c16f..558b2968ca 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -129,6 +129,12 @@ pub fn tools() -> Vec { "dpi": {"type":"number","minimum":72,"maximum":600}, "language": {"type":"string"}, "skip_text_pages": {"type":"boolean"} }), &["paths","out_dir","steps"])), + t("attachment_add", "Attach a file", "Attach the file at path to the document (document level, the Attachments panel; not a paper-clip comment on a page). name defaults to the file's name; the attachment's key, unique in the document, is returned. Undoable.") + .cmd("edit.attach") + .with(schema(json!({"doc": doc(), "path": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "path"])), + t("attachment_delete", "Delete an attachment", "Remove the document-level attachment with this name (as doc_info lists it). Undoable.") + .destructive() + .with(schema(json!({"doc": doc(), "name": {"type": "string"}}), &["doc", "name"])), t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.") .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 0ff3930b4a..3249cff552 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5672,3 +5672,43 @@ fn absolute_gotor_consent_does_not_bypass_validation_or_passwords() { assert_eq!(opened["document"]["encrypted"], true); assert_eq!(opened["destination_applied"], true); } + +#[test] +fn attaching_and_deleting_files_through_the_tools() { + let dir = workdir("attach"); + std::fs::write(dir.join("notes.txt"), b"keep this with the PDF").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"].as_array().unwrap().is_empty()); + // A text file and a PDF, the PDF under a name of its own; the second notes.txt is kept apart. + let r = ok(&mut a, "attachment_add", json!({ "doc": doc, "path": "notes.txt", "description": "Meeting notes" })); + assert_eq!(r["name"], "notes.txt"); + let r = ok(&mut a, "attachment_add", json!({ "doc": doc, "path": "b.pdf", "name": "annex.pdf" })); + assert_eq!(r["name"], "annex.pdf"); + let r = ok(&mut a, "attachment_add", json!({ "doc": doc, "path": "notes.txt" })); + assert_eq!(r["name"], "notes.txt (2)"); + let listed = ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"].clone(); + let names: Vec<&str> = listed.as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["annex.pdf", "notes.txt", "notes.txt (2)"]); + assert_eq!(listed[1]["description"], "Meeting notes"); + assert_eq!(listed[1]["size"], 22); + // Saved and reopened, the attachment is there with its bytes. + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "with-notes.pdf" })); + let saved = std::fs::read(dir.join("with-notes.pdf")).unwrap(); + let info = pdfcraft_render::inspect(std::sync::Arc::new(saved.clone()), None).unwrap(); + let att = info.attachments.iter().find(|x| x.name == "notes.txt").expect("notes.txt is in the file"); + assert_eq!(pdfcraft_render::attachment_data(&saved, None, att).unwrap(), b"keep this with the PDF"); + // Deleting one leaves the others; a name that is not there is refused; undo brings it back. + ok(&mut a, "attachment_delete", json!({ "doc": doc, "name": "notes.txt" })); + let names: Vec = ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"] + .as_array() + .unwrap() + .iter() + .map(|x| x["name"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(names, ["annex.pdf", "notes.txt (2)"]); + assert!(a.call("attachment_delete", &json!({ "doc": doc, "name": "notes.txt" })).is_err()); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"].as_array().unwrap().len(), 3); + assert!(a.call("attachment_add", &json!({ "doc": doc, "path": "missing.txt" })).is_err(), "a file that is not there is refused"); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 991c86a9ca..6f7e25f851 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -133,7 +133,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Link", "link-2", "edit.link", Ready), item("Bates numbering", "hash", "edit.bates", Ready), item("Background", "palette", "edit.background", Ready), - item("Attach file", "paperclip", "edit.attach", Planned("M12")), + item("Attach file", "paperclip", "edit.attach", Ready), ], }, ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index c3294dc1e2..5fd78441de 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -267,6 +267,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("edit.text", "Add text", None, None, Modification, "type"), c("edit.edit_text", "Edit text & images", None, None, Modification, "text-cursor-input"), c("edit.link", "Add or edit links", None, None, Modification, "link-2"), + c("edit.attach", "Attach a file…", EDIT, None, Modification, "paperclip"), c("edit.links_from_urls", "Create links from URLs", None, None, Modification, "link-2"), c("edit.remove_links", "Remove all links", None, None, Modification, "trash-2"), c("edit.bates", "Add Bates numbering…", None, None, Modification, "hash"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 15c0551b14..97a46bcc06 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -41,7 +41,7 @@ pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use text_preview::TextEditPreview; -pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, split_ranges}; +pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, mime_for_name, split_ranges}; /// One file produced by a split: (1-based first page, last page, PDF bytes). pub use pdfcraft_organize::LabelStyle; @@ -197,6 +197,8 @@ fn uses_scripts(edit: &Edit) -> bool { fn scope_of(edit: &Edit) -> Scope { match edit { Edit::SetInfo { .. } => Scope::Metadata, + // The Attachments list is read from the whole document. + Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, // A file attachment also changes the Attachments list. Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, Edit::AddMeasurement(_) @@ -1330,6 +1332,18 @@ pub enum Edit { }, /// Document Properties ▸ Initial View (and the language and binding). SetInitialView(Box), + /// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under + /// `name`, made unique; `mime` is the embedded file's `/Subtype` when known. + AddAttachment { + name: String, + bytes: Vec, + description: Option, + mime: Option, + }, + /// Remove the document-level attachment whose name-tree key is `key`. + DeleteAttachment { + key: String, + }, /// Build a tagged structure tree for an untagged, text-only document. AutoTagText, /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is @@ -1813,6 +1827,8 @@ impl Edit { Edit::DuplicateField { .. } => Label::of("Duplicate field"), Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), Edit::SetInitialView(_) => Label::of("Change initial view"), + Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), + Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), Edit::AutoTagText => Label::of("Automatically tag text"), Edit::SetAltText { .. } => Label::of("Set alternate text"), Edit::SetTagProperty { .. } => Label::of("Change tag properties"), @@ -2002,6 +2018,8 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } | Edit::SetInitialView(_) + | Edit::AddAttachment { .. } + | Edit::DeleteAttachment { .. } | Edit::AutoTagText | Edit::SetAltText { .. } | Edit::SetTagProperty { .. } @@ -2192,6 +2210,10 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::EraseInk { page, index, path, radius } => { pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; } + Edit::AddAttachment { name, bytes, description, mime } => { + pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; + } + Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, Edit::ReplaceText { page, quads, text, author, strike, caret } => { pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &cx.meta())?; } diff --git a/crates/organize/src/attachments.rs b/crates/organize/src/attachments.rs new file mode 100644 index 0000000000..d785f1a4db --- /dev/null +++ b/crates/organize/src/attachments.rs @@ -0,0 +1,159 @@ +//! Document-level file attachments (Acrobat: Edit ▸ Attach file; the Attachments panel): the +//! `/Names /EmbeddedFiles` name tree of file specifications, each with its embedded stream. +//! +//! A FileAttachment annotation (a paper clip on a page) is a comment and lives in the annot +//! crate; this is the document level only. + +use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; + +use crate::OrganizeError; +use crate::import::{name_tree_entries, set_attachments}; + +/// The keys of the document-level attachments, in name-tree order. +pub fn attachment_keys(doc: &Document) -> Vec { + entries(doc).into_iter().map(|(k, _)| PdfString::literal(k).to_text()).collect() +} + +/// Attach `bytes` to the document under `name` (a file name; any folder part is dropped). The +/// name tree's key is the name, made unique with " (2)", " (3)"… when it is already taken, and +/// is returned so the caller can find the attachment again. `date` is a PDF date string for the +/// embedded file's `/ModDate`; `mime` its `/Subtype` when known. +pub fn add_attachment( + doc: &mut Document, + name: &str, + bytes: &[u8], + description: Option<&str>, + mime: Option<&str>, + date: Option<&str>, +) -> Result { + let name = file_name(name); + if name.is_empty() { + return Err(OrganizeError::Invalid("the attachment needs a file name".into())); + } + let mut all = entries(doc); + let taken: Vec = all.iter().map(|(k, _)| PdfString::literal(k.clone()).to_text()).collect(); + let mut key = name.clone(); + let mut n = 2; + while taken.contains(&key) { + key = format!("{name} ({n})"); + n += 1; + } + + let mut params = Dict::new(); + params.set(b"Size".to_vec(), Object::Int(bytes.len() as i64)); + if let Some(d) = date { + params.set(b"ModDate".to_vec(), Object::String(PdfString::literal(d.as_bytes().to_vec()))); + } + let mut ef = Dict::new(); + ef.set(b"Type".to_vec(), Object::name("EmbeddedFile")); + if let Some(m) = mime.map(str::trim).filter(|m| !m.is_empty()) { + ef.set(b"Subtype".to_vec(), Object::Name(m.as_bytes().to_vec())); + } + ef.set(b"Params".to_vec(), Object::Dict(params)); + let stream = doc.add(Object::Stream(Stream::flate(ef, bytes))); + + let mut spec = Dict::new(); + spec.set(b"Type".to_vec(), Object::name("Filespec")); + spec.set(b"F".to_vec(), Object::String(PdfString::text(&key))); + spec.set(b"UF".to_vec(), Object::String(PdfString::text(&key))); + if let Some(d) = description.map(str::trim).filter(|d| !d.is_empty()) { + spec.set(b"Desc".to_vec(), Object::String(PdfString::text(d))); + } + let mut files = Dict::new(); + files.set(b"F".to_vec(), Object::Ref(stream)); + files.set(b"UF".to_vec(), Object::Ref(stream)); + spec.set(b"EF".to_vec(), Object::Dict(files)); + let spec = doc.add(spec); + + all.push((PdfString::text(&key).bytes, Object::Ref(spec))); + set_attachments(doc, all)?; + Ok(key) +} + +/// Remove the document-level attachment whose name-tree key is `key`. The embedded stream is +/// left for the writer's garbage collection, as with every other removal. +pub fn delete_attachment(doc: &mut Document, key: &str) -> Result<(), OrganizeError> { + let mut all = entries(doc); + let before = all.len(); + all.retain(|(k, _)| PdfString::literal(k.clone()).to_text() != key); + if all.len() == before { + return Err(OrganizeError::Invalid(format!("there is no attachment named {key}"))); + } + if all.is_empty() { clear_embedded_files(doc) } else { set_attachments(doc, all) } +} + +/// The `/EmbeddedFiles` entries (key, file specification), in tree order. +fn entries(doc: &Document) -> Vec<(Vec, Object)> { + let Some(tree) = doc + .root() + .and_then(|r| doc.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"Names").map(|n| doc.resolve(n))) + .and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned())) + else { + return Vec::new(); + }; + let mut out = Vec::new(); + name_tree_entries(doc, &tree, 0, &mut out); + out +} + +/// Drop `/EmbeddedFiles` from the catalog's `/Names` (and `/Names` itself when nothing is left). +fn clear_embedded_files(doc: &mut Document) -> Result<(), OrganizeError> { + let root = doc.root().ok_or(OrganizeError::NoPageTree)?; + let names = doc.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()); + match names { + Some(Object::Ref(r)) => { + doc.update_dict(r, |n| { + n.remove(b"EmbeddedFiles"); + })?; + } + Some(Object::Dict(mut n)) => { + n.remove(b"EmbeddedFiles"); + doc.update_dict(root, |c| { + if n.is_empty() { + c.remove(b"Names"); + } else { + c.set(b"Names".to_vec(), Object::Dict(n)); + } + })?; + } + _ => {} + } + Ok(()) +} + +/// The last path component of `name`, trimmed: a dragged-in path or a folder part never becomes +/// the attachment's name. +fn file_name(name: &str) -> String { + name.rsplit(['/', '\\']).next().unwrap_or(name).trim().to_string() +} + +/// A media type from the file's extension, for the embedded file's `/Subtype`; `None` when it +/// is not one of the common kinds (the attachment is still fine without it). +pub fn mime_for_name(name: &str) -> Option<&'static str> { + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?; + Some(match ext.as_str() { + "pdf" => "application/pdf", + "txt" | "log" | "md" => "text/plain", + "csv" => "text/csv", + "htm" | "html" => "text/html", + "xml" => "text/xml", + "json" => "application/json", + "zip" => "application/zip", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "tif" | "tiff" => "image/tiff", + "svg" => "image/svg+xml", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" => "application/vnd.ms-powerpoint", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "rtf" => "application/rtf", + "eml" => "message/rfc822", + "msg" => "application/vnd.ms-outlook", + _ => return None, + }) +} diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index c420d2d59c..37684882c6 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -486,7 +486,7 @@ fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8) -> Opt } /// All (key, value) pairs of a name tree, in order. -fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &mut Vec<(Vec, Object)>) { +pub(crate) fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &mut Vec<(Vec, Object)>) { if depth > 32 { return; } @@ -1075,18 +1075,24 @@ fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { +pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> Result<(), OrganizeError> { if entries.is_empty() { return Ok(()); } + // Names are compared and numbered as text: a key written as UTF-16 (a name with an accent + // or Chinese letters) must not get ASCII bytes appended to it. let mut seen = std::collections::HashSet::new(); for (k, _) in entries.iter_mut() { - let base = k.clone(); + let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); + let mut text = base.clone(); let mut n = 2; - while !seen.insert(k.clone()) { - *k = [base.as_slice(), format!(" ({n})").as_bytes()].concat(); + while !seen.insert(text.clone()) { + text = format!("{base} ({n})"); n += 1; } + if text != base { + *k = pdfcraft_cos::PdfString::text(&text).bytes; + } } entries.sort_by(|a, b| a.0.cmp(&b.0)); let mut names = Vec::new(); @@ -1098,6 +1104,14 @@ fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> R tree.set(b"Names".to_vec(), Object::Array(names)); let tree = dst.add(tree); let root = dst.root().ok_or(OrganizeError::NoPageTree)?; + // `/Names` may be an indirect dictionary (Acrobat writes it so): update it where it is, so + // its other trees (/Dests, /JavaScript…) stay. + if let Some(Object::Ref(names)) = dst.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()) + && dst.get(names).as_dict().is_some() + { + dst.update_dict(names, |n| n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree)))?; + return Ok(()); + } dst.update_dict(root, |c| { let mut n = match c.get(b"Names") { Some(Object::Dict(d)) => d.clone(), diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 7b366bad9d..f008bb11b6 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -13,6 +13,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; +mod attachments; mod boxes; mod dedupe; mod import; @@ -23,6 +24,7 @@ mod prune; pub mod view; mod xmp; +pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name}; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; pub use dedupe::dedupe_resources; pub use import::{ diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index e07af72a1c..4bb3b1e653 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2441,3 +2441,84 @@ fn combine_field_conflicts_previews_each_file_in_order() { assert_eq!(preview[1], [("Text1".to_string(), "Text1-1".to_string())]); assert_eq!(preview[2], [("Text1".to_string(), "Text1-2".to_string())], "the cascade continues past the earlier rename"); } + +/// What Edit ▸ Attach file added survives Combine files (upstream's writer), names +/// with accents included, and the combined document takes more attachments and deletions. +#[test] +fn attachments_added_in_the_app_survive_combine_and_more_can_be_attached() { + let embedded = |doc: &Document, key: &str| -> (Vec, Option) { + let names = doc.resolve(catalog(doc).get(b"Names").unwrap()).as_dict().cloned().unwrap(); + let tree = doc.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap(); + let pairs = tree.get(b"Names").and_then(|n| n.as_array()).cloned().unwrap(); + let at = pairs.chunks(2).position(|p| p[0].as_string().unwrap().to_text() == key).unwrap_or_else(|| panic!("no attachment {key}")); + let spec = doc.resolve(&pairs[at * 2 + 1]).as_dict().cloned().unwrap(); + let desc = spec.get(b"Desc").and_then(Object::as_string).map(|s| s.to_text()); + let ef = spec.get(b"EF").and_then(Object::as_dict).unwrap().get(b"F").cloned().unwrap(); + let Object::Stream(stream) = doc.resolve(&ef).as_ref().clone() else { panic!("the embedded file is a stream") }; + (stream.decoded().unwrap(), desc) + }; + let mut a = Document::open(Arc::new(fixture())).unwrap(); + add_attachment(&mut a, "invoice.csv", b"a,b", Some("March"), Some("text/csv"), None).unwrap(); + add_attachment(&mut a, "año.txt", b"uno", None, None, None).unwrap(); + let mut b = Document::open(Arc::new(fixture())).unwrap(); + add_attachment(&mut b, "invoice.csv", b"x,y", None, None, None).unwrap(); + add_attachment(&mut b, "año.txt", b"dos", None, None, None).unwrap(); + let a = save_and_reopen(&a); + let b = save_and_reopen(&b); + let mut out = full_roundtrip(&combine(&[("A", &a), ("B", &b)]).unwrap()); + // Both files' attachments, the second copies numbered as text (not bytes glued onto UTF-16). + assert_eq!(attachment_keys(&out), ["invoice.csv", "invoice.csv (2)", "año.txt", "año.txt (2)"]); + assert_eq!(embedded(&out, "invoice.csv"), (b"a,b".to_vec(), Some("March".into()))); + assert_eq!(embedded(&out, "invoice.csv (2)"), (b"x,y".to_vec(), None)); + assert_eq!(embedded(&out, "año.txt (2)").0, b"dos"); + // The combined document goes on taking attachments, and losing them, like any other. + assert_eq!(add_attachment(&mut out, "invoice.csv", b"z", None, None, None).unwrap(), "invoice.csv (3)"); + delete_attachment(&mut out, "invoice.csv (2)").unwrap(); + let out = full_roundtrip(&out); + assert_eq!(attachment_keys(&out), ["invoice.csv", "invoice.csv (3)", "año.txt", "año.txt (2)"]); + assert_eq!(embedded(&out, "invoice.csv (3)").0, b"z"); +} + +#[test] +fn attachments_are_added_listed_and_deleted() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + assert!(attachment_keys(&doc).is_empty()); + // A path loses its folder; the key is the file name. + let key = add_attachment(&mut doc, "/Users/eva/Desktop/invoice.csv", b"a,b\n1,2\n", Some("March"), Some("text/csv"), Some("D:20261012120000Z")) + .unwrap(); + assert_eq!(key, "invoice.csv"); + // The same name again is kept apart. + assert_eq!(add_attachment(&mut doc, "invoice.csv", b"x", None, None, None).unwrap(), "invoice.csv (2)"); + assert_eq!(add_attachment(&mut doc, " ", b"x", None, None, None), Err(OrganizeError::Invalid("the attachment needs a file name".into()))); + let doc = save_and_reopen(&doc); + assert_eq!(attachment_keys(&doc), ["invoice.csv", "invoice.csv (2)"]); + // What was written: a Filespec with the embedded stream, its size and type, and the description. + let root = doc.root().unwrap(); + let names = doc.get(root).as_dict().unwrap().get(b"Names").cloned().unwrap(); + let tree = doc.resolve(&names).as_dict().unwrap().get(b"EmbeddedFiles").cloned().unwrap(); + let tree = doc.resolve(&tree); + let pairs = tree.as_dict().unwrap().get(b"Names").cloned().unwrap(); + let pairs = doc.resolve(&pairs).as_array().cloned().unwrap(); + assert_eq!(pairs.len(), 4); + let spec = doc.resolve(&pairs[1]); + let spec = spec.as_dict().unwrap(); + assert_eq!(spec.get(b"Type"), Some(&Object::name("Filespec"))); + assert_eq!(spec.get(b"UF").and_then(Object::as_string).map(PdfString::to_text).as_deref(), Some("invoice.csv")); + assert_eq!(spec.get(b"Desc").and_then(Object::as_string).map(PdfString::to_text).as_deref(), Some("March")); + let ef = spec.get(b"EF").and_then(Object::as_dict).unwrap().get(b"F").cloned().unwrap(); + let ef = doc.resolve(&ef); + let Object::Stream(stream) = ef.as_ref() else { panic!("the embedded file is a stream") }; + assert_eq!(stream.dict.get(b"Subtype"), Some(&Object::Name(b"text/csv".to_vec()))); + let params = stream.dict.get(b"Params").and_then(Object::as_dict).unwrap(); + assert_eq!(params.get(b"Size"), Some(&Object::Int(8))); + assert_eq!(stream.decoded().unwrap(), b"a,b\n1,2\n"); + // Deleting: the one named goes, the other stays; the last one takes the tree with it. + let mut doc = doc; + delete_attachment(&mut doc, "invoice.csv").unwrap(); + assert_eq!(attachment_keys(&doc), ["invoice.csv (2)"]); + assert_eq!(delete_attachment(&mut doc, "invoice.csv"), Err(OrganizeError::Invalid("there is no attachment named invoice.csv".into()))); + delete_attachment(&mut doc, "invoice.csv (2)").unwrap(); + let doc = save_and_reopen(&doc); + assert!(attachment_keys(&doc).is_empty()); + assert!(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Names").is_none()); +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 7d7b6b9c58..75dd719594 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -185,6 +185,7 @@ impl PdfCraftApp { self.protect_draft = Default::default(); self.dialog = Some(Dialog::Protect); } + "edit.attach" => self.attach_file_dialog(), "protect.remove" => { if self.apply_edit(Edit::RemoveProtection) { self.notify_tr("Security will be removed when you save"); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 222865d72b..0b2b8eed60 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2373,3 +2373,8 @@ animation effects Off معطّلة This document is trying to open a local file: يحاول هذا المستند فتح ملف محلي: Move tag earlier نقل العلامة للأعلى Move tag later نقل العلامة للأسفل + Attach a file… إرفاق ملف… + Delete attachment حذف المرفق + Attaching files is not available on the web yet إرفاق الملفات غير متاح على الويب بعد + Attach {name} إرفاق {name} + Delete attachment {key} حذف المرفق {key} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 17a0890fa5..73ae509bc1 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2315,3 +2315,8 @@ animation effects Off Изключени This document is trying to open a local file: Този документ се опитва да отвори локален файл: Move tag earlier Преместване на етикета по-рано Move tag later Преместване на етикета по-късно + Attach a file… Прикачване на файл… + Delete attachment Изтриване на прикачения файл + Attaching files is not available on the web yet Прикачването на файлове още не е налично в уеб версията + Attach {name} Прикачване на {name} + Delete attachment {key} Изтриване на прикачения файл {key} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e6e29c8bba..5d78d54720 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2426,3 +2426,8 @@ animation effects Off Vypnuto This document is trying to open a local file: Tento dokument se pokouší otevřít místní soubor: Move tag earlier Přesunout značku dříve Move tag later Přesunout značku později + Attach a file… Připojit soubor… + Delete attachment Odstranit přílohu + Attaching files is not available on the web yet Připojování souborů zatím na webu není k dispozici + Attach {name} Připojit {name} + Delete attachment {key} Odstranit přílohu {key} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 38ef665d13..b75f4913af 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2353,3 +2353,8 @@ animation effects Off Aus This document is trying to open a local file: Dieses Dokument versucht, eine lokale Datei zu öffnen: Move tag earlier Tag früher verschieben Move tag later Tag später verschieben + Attach a file… Datei anhängen… + Delete attachment Anhang löschen + Attaching files is not available on the web yet Dateien anhängen ist im Web noch nicht verfügbar + Attach {name} {name} anhängen + Delete attachment {key} Anhang {key} löschen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 106278f012..b66a1bc6d2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2362,3 +2362,8 @@ animation effects Off Ανενεργά This document is trying to open a local file: Αυτό το έγγραφο προσπαθεί να ανοίξει ένα τοπικό αρχείο: Move tag earlier Μετακίνηση ετικέτας νωρίτερα Move tag later Μετακίνηση ετικέτας αργότερα + Attach a file… Επισύναψη αρχείου… + Delete attachment Διαγραφή συνημμένου + Attaching files is not available on the web yet Η επισύναψη αρχείων δεν είναι ακόμη διαθέσιμη στον ιστό + Attach {name} Επισύναψη {name} + Delete attachment {key} Διαγραφή συνημμένου {key} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5afbe4f2ef..43bbbbc35b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2482,3 +2482,8 @@ animation effects Off Desactivados This document is trying to open a local file: Este documento intenta abrir un archivo local: Move tag earlier Mover etiqueta antes Move tag later Mover etiqueta después + Attach a file… Adjuntar un archivo… + Delete attachment Eliminar adjunto + Attaching files is not available on the web yet Adjuntar archivos aún no está disponible en la web + Attach {name} Adjuntar {name} + Delete attachment {key} Eliminar adjunto {key} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 79389837d3..c2c3beec26 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2352,3 +2352,8 @@ animation effects Off Désactivés This document is trying to open a local file: Ce document tente d’ouvrir un fichier local : Move tag earlier Déplacer la balise plus tôt Move tag later Déplacer la balise plus tard + Attach a file… Joindre un fichier… + Delete attachment Supprimer la pièce jointe + Attaching files is not available on the web yet Joindre des fichiers n'est pas encore disponible sur le web + Attach {name} Joindre {name} + Delete attachment {key} Supprimer la pièce jointe {key} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3d6e1e3af5..9d215c571a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2351,3 +2351,8 @@ animation effects Off Ki This document is trying to open a local file: Ez a dokumentum egy helyi fájlt próbál megnyitni: Move tag earlier Címke korábbra helyezése Move tag later Címke későbbre helyezése + Attach a file… Fájl csatolása… + Delete attachment Melléklet törlése + Attaching files is not available on the web yet Fájlok csatolása a weben még nem érhető el + Attach {name} {name} csatolása + Delete attachment {key} {key} melléklet törlése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3575ab374d..282db62fcc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2361,3 +2361,8 @@ animation effects Off Disattivati This document is trying to open a local file: Questo documento sta tentando di aprire un file locale: Move tag earlier Sposta tag prima Move tag later Sposta tag dopo + Attach a file… Allega un file… + Delete attachment Elimina allegato + Attaching files is not available on the web yet Allegare file non è ancora disponibile sul web + Attach {name} Allega {name} + Delete attachment {key} Elimina allegato {key} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5e61802c35..a33b764e7d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2352,3 +2352,8 @@ animation effects Off オフ This document is trying to open a local file: この文書はローカルファイルを開こうとしています: Move tag earlier タグを前へ移動 Move tag later タグを後へ移動 + Attach a file… ファイルを添付… + Delete attachment 添付ファイルを削除 + Attaching files is not available on the web yet Web 版ではまだファイルを添付できません + Attach {name} {name} を添付 + Delete attachment {key} 添付ファイル {key} を削除 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 8e4a090403..5d76591339 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2350,3 +2350,8 @@ animation effects Off Өшірулі This document is trying to open a local file: Бұл құжат жергілікті файлды ашуға әрекеттеніп жатыр: Move tag earlier Тегті ертерек жылжыту Move tag later Тегті кейінірек жылжыту + Attach a file… Файл тіркеу… + Delete attachment Тіркемені жою + Attaching files is not available on the web yet Вебте файл тіркеу әзірге қолжетімсіз + Attach {name} {name} тіркеу + Delete attachment {key} {key} тіркемесін жою diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index ae61e396ff..410bd7c7b5 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2596,3 +2596,8 @@ key Space Tarpas This document is trying to open a local file: Šis dokumentas bando atverti vietinį failą: Move tag earlier Perkelti žymą anksčiau Move tag later Perkelti žymą vėliau + Attach a file… Pridėti failą… + Delete attachment Ištrinti priedą + Attaching files is not available on the web yet Failų pridėjimas žiniatinklyje dar negalimas + Attach {name} Pridėti {name} + Delete attachment {key} Ištrinti priedą {key} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 310a990f7d..395b905033 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2332,3 +2332,8 @@ animation effects Off Wyłączone This document is trying to open a local file: Ten dokument próbuje otworzyć plik lokalny: Move tag earlier Przenieś znacznik wcześniej Move tag later Przenieś znacznik później + Attach a file… Dołącz plik… + Delete attachment Usuń załącznik + Attaching files is not available on the web yet Dołączanie plików nie jest jeszcze dostępne w wersji webowej + Attach {name} Dołącz {name} + Delete attachment {key} Usuń załącznik {key} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 623d69ce80..cd6da1c53c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2366,3 +2366,8 @@ animation effects Off Desativados This document is trying to open a local file: Este documento está tentando abrir um arquivo local: Move tag earlier Mover tag para antes Move tag later Mover tag para depois + Attach a file… Anexar um arquivo… + Delete attachment Excluir anexo + Attaching files is not available on the web yet Anexar arquivos ainda não está disponível na web + Attach {name} Anexar {name} + Delete attachment {key} Excluir anexo {key} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7f628088d6..cc7c4d2b24 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2265,3 +2265,8 @@ animation effects Off Выкл This document is trying to open a local file: Этот документ пытается открыть локальный файл: Move tag earlier Переместить тег раньше Move tag later Переместить тег позже + Attach a file… Прикрепить файл… + Delete attachment Удалить вложение + Attaching files is not available on the web yet Прикрепление файлов в веб-версии пока недоступно + Attach {name} Прикрепить {name} + Delete attachment {key} Удалить вложение {key} diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 3feaad354d..1ef69378f1 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2357,3 +2357,8 @@ animation effects Off Izklopljeni This document is trying to open a local file: Ta dokument poskuša odpreti lokalno datoteko: Move tag earlier Premakni oznako prej Move tag later Premakni oznako pozneje + Attach a file… Priloži datoteko… + Delete attachment Izbriši prilogo + Attaching files is not available on the web yet Prilaganje datotek na spletu še ni na voljo + Attach {name} Priloži {name} + Delete attachment {key} Izbriši prilogo {key} diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index d15365f3d4..4ef063f9b6 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2265,3 +2265,8 @@ animation effects Off ఆఫ్ This document is trying to open a local file: ఈ పత్రం ఒక స్థానిక ఫైల్‌ను తెరవడానికి ప్రయత్నిస్తోంది: Move tag earlier ట్యాగ్‌ను ముందుకు తరలించు Move tag later ట్యాగ్‌ను వెనుకకు తరలించు + Attach a file… ఫైల్‌ను జోడించు… + Delete attachment జోడింపును తొలగించు + Attaching files is not available on the web yet వెబ్‌లో ఫైల్‌లను జోడించడం ఇంకా అందుబాటులో లేదు + Attach {name} {name}ను జోడించు + Delete attachment {key} జోడింపు {key}ను తొలగించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 44fd05f074..eed722a897 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2348,3 +2348,8 @@ animation effects Off Kapalı This document is trying to open a local file: Bu belge yerel bir dosya açmaya çalışıyor: Move tag earlier Etiketi daha erken taşı Move tag later Etiketi daha geç taşı + Attach a file… Dosya ekle… + Delete attachment Eki sil + Attaching files is not available on the web yet Dosya eklemek web'de henüz kullanılamıyor + Attach {name} {name} ekle + Delete attachment {key} {key} ekini sil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 648dc855fe..c77239d528 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2374,3 +2374,8 @@ animation effects Off Вимкнено This document is trying to open a local file: Цей документ намагається відкрити локальний файл: Move tag earlier Перемістити тег раніше Move tag later Перемістити тег пізніше + Attach a file… Прикріпити файл… + Delete attachment Видалити вкладення + Attaching files is not available on the web yet Прикріплення файлів у веб-версії поки недоступне + Attach {name} Прикріпити {name} + Delete attachment {key} Видалити вкладення {key} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 391c8eda7c..df6b067e72 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2316,3 +2316,8 @@ animation effects Off 关 This document is trying to open a local file: 此文档正尝试打开本地文件: Move tag earlier 将标签前移 Move tag later 将标签后移 + Attach a file… 附加文件… + Delete attachment 删除附件 + Attaching files is not available on the web yet 网页版尚不支持附加文件 + Attach {name} 附加 {name} + Delete attachment {key} 删除附件 {key} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index da45921c21..db8ebd1569 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2226,3 +2226,8 @@ animation effects Off 關 This document is trying to open a local file: 此文件正嘗試開啟本機檔案: Move tag earlier 將標記前移 Move tag later 將標記後移 + Attach a file… 附加檔案… + Delete attachment 刪除附件 + Attaching files is not available on the web yet 網頁版尚不支援附加檔案 + Attach {name} 附加 {name} + Delete attachment {key} 刪除附件 {key} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ea3da00957..9e952b1c33 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1440,6 +1440,38 @@ impl PdfCraftApp { } /// Save an attachment to disk, or open a PDF attachment in a new tab. + /// Edit ▸ Attach a file…: choose a file, then attach it to the active document and show + /// the Attachments panel with it. + pub fn attach_file_dialog(&mut self) { + if self.active_ids().is_none() { + return; + } + #[cfg(not(target_arch = "wasm32"))] + { + let dialog = rfd::AsyncFileDialog::new().set_title(tl!("Attach a file").to_string()); + self.ask_one(pickers::Ask::File(dialog), None, |app, path| app.attach_file(&path)); + } + #[cfg(target_arch = "wasm32")] + self.notify_tr("Attaching files is not available on the web yet"); + } + + /// Attach the file at `path` to the active document (document level: the Attachments + /// panel, not a paper-clip comment on a page). + pub fn attach_file(&mut self, path: &std::path::Path) { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) => { + self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]); + return; + } + }; + let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string); + if self.apply_edit(pdfcraft_engine::Edit::AddAttachment { name, bytes, description: None, mime }) { + self.choose_right_panel(Some(RightPanel::Attachments)); + } + } + pub fn attachment_action(&mut self, doc: DocId, index: usize, open: bool) { let Some(d) = self.session.get(doc) else { return }; let Some(att) = d.info.attachments.get(index).cloned() else { return }; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bdd625804d..66d8fa07a4 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -680,6 +680,14 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { RightPanel::Search => crate::search_ui::panel(ui, &t, view, info.pages.len()), RightPanel::Compare => compare_action = crate::compare_ui::panel(ui, &t, compare, id), RightPanel::Attachments => { + let attachable = doc.allows_modification() && doc.read_only_reason.is_none(); + if attachable { + egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| { + if ui.add(egui::Button::new(tl!("Attach a file…"))).clicked() { + panel_command = Some("edit.attach"); + } + }); + } if info.attachments.is_empty() { empty(ui, &t, "paperclip", "This document has no attachments."); } @@ -712,6 +720,12 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { { attachment_action = Some((ai, true)); } + if let pdfcraft_render::AttachmentSource::Document { key } = &a.source + && attachable + && icons::button(ui, "trash-2", 28.0, false, tl!("Delete attachment")).clicked() + { + panel_edit = Some(pdfcraft_engine::Edit::DeleteAttachment { key: key.clone() }); + } }); }); }); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8f4194b932..e3d02b6718 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3325,11 +3325,11 @@ title = "Add file attachments" area = "D" tier = "P2" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["edit.attach"] +tools = ["attachment_add"] +evidence = ["crates/organize/src/tests.rs::attachments_are_added_listed_and_deleted", "crates/organize/src/tests.rs::attachments_added_in_the_app_survive_combine_and_more_can_be_attached", "crates/automation/tests/automation.rs::attaching_and_deleting_files_through_the_tools"] +notes = "Edit ▸ Attach a file… (also the Attach a file… button in the Attachments panel and the Edit a PDF list): the file goes into /Names /EmbeddedFiles as a Filespec with a Flate-compressed EmbeddedFile stream (/Size, /ModDate, /Subtype from the extension); a name already taken gets \" [[feature]] id = "organize.attachments-delete" @@ -3337,11 +3337,11 @@ title = "Delete attachments" area = "D" tier = "P2" milestone = "M12" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["attachment_delete"] +evidence = ["crates/organize/src/tests.rs::attachments_are_added_listed_and_deleted", "crates/organize/src/tests.rs::attachments_added_in_the_app_survive_combine_and_more_can_be_attached", "crates/automation/tests/automation.rs::attaching_and_deleting_files_through_the_tools"] +notes = "The trash button on a document-level row of the Attachments panel; the last one removed takes the /EmbeddedFiles tree with it. Undoable. A paper-clip comment is deleted as a comment." [[feature]] id = "organize.attachments-description" From c01de647992f7f23bcb8c4b844b82d2bc9dd2124 Mon Sep 17 00:00:00 2001 From: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:40:08 -0400 Subject: [PATCH 006/517] ATTRIBUTION.toml: the language files' checksums after the new rows cargo xtask assets checks each catalog's sha256; the rows this branch adds changed the files without updating it. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.toml | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 28785f7675..0bb17a96f5 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d0a69a35403e70b94c3adfef88b41ec719f4f6ab700d351c8f82a78c53b63ef6" +sha256 = "fd398f60de196e4f1eda6b33b35572778e35e99ed2bdf6bf3b049f95e22139c6" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "caa23201b7f25dba9f8dc579771e13435e5e8c72a7efb9770e917a2a33003a39" +sha256 = "0624ced479e67bd907fcaa6f520185eb463cf02ddb56b680c5f0439d2434efc3" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5ead9fccdca17810a3a98de3fa499d8a2469a1ff015c3d0e11880ca629295ea1" +sha256 = "4305571e892ddbf24cd2b6b13f0a1e9e68e9e0d11984782a22263a1b90a31b5a" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d48f9d68711850cae48af53f7691facee4209f7846e3eb2c064d97dad2ae2426" +sha256 = "52bcf4613e8065fa9e8bacdd71935fb348761e96ee6417a45ccdbb85ce910c79" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6bc2edec99534d0df9b5e4a1c2e18dc32ad84c32e404c74dd437d86119b1d6ea" +sha256 = "6bc73e96c2912197563423fb4e1727b0c7a139e63453537223297a7aaee761a9" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -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 = "cb6c04bd36ba6ce0ebc808da2e47b65797e14d59466e93802adec4d4f5056e3b" +sha256 = "bd467093e7348673b5c0e9989df16a8eb3cee1448bbde6007013b3ccb630594b" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0368944155991c2ea304788418fb326f30e45649539a0cf49539194b026ca104" +sha256 = "51faaebbbe3e6a1d9dfc424fde307af21c9070507fe13ec18f1bab24f734db5e" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f4969c353f1424c6b9b5b110820c69dc59907590d013221511eae3f86c29024e" +sha256 = "97f484e5547e8240f6071a336c93aeac9ac77ed184a3029ede41f972a6858fac" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4bb6fa4f3b6e236a4a353df79acd0c5528ea8354058eeaeb66c28bc0f7e02939" +sha256 = "7faa6fe00c3963192bd01ab7dd9fe804f848fea0cbc38fb80cd9f29336f54f82" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "adcbdfc683589b067e062a6200084bf9192935b297f5b616f8ed39db9c71db9b" +sha256 = "ce09066babc87ee26e2a8f7947e06a7a23ff3e44c43918b075126dd90fded982" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "aebe63cd91df2d2f9d2bdc1da43b017823dbc8a32135e999b9ac7041277be0c8" +sha256 = "95c03faec11809638b52666c44d43728ff41f64a5782c4cb6eb896af71243b65" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3c8e3a28a5b751c6d6384809d4fd81c14c2ff5f561aaee5b58d24fbfa2ca4dc1" +sha256 = "daa0ab11a97116d53c9a5e3e785f3b3ed59c3465e52c432ab34a58dada1ee643" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "637fcfdd0bf1898b413af7ccbc2eb4228bf656273de9f3ef9241bd128940aeab" +sha256 = "ed8dd331e9cdca7757ffdf84331f9762c479236e07c17ee7c2bb79ac6692c27b" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5285f8987bd8c265f73ff443906c1832a0886037592d334974cf539b9f972a3f" +sha256 = "0210918100662a02353fcea542591eb69d580859ca6326f26382e620935035d0" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "23779abc08be40ddfb305fd638fe280c854ac33473b2822006ba8bad399c0cad" +sha256 = "dd52233caa0048c25b48eb5bc7db6bc1b188fe43dcef34bfb461492418c60351" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c5129c40b202d8467d8d4cf0a189da8c2ba2af711998edb73f67e1b291289a04" +sha256 = "070f30d6569b37ea4bf0f653caba90af3d422d9f8ad6cc4147f5402245eac761" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "11ed2aa56dc5f8f6656eba717500c74bb8c8f931c08f9f9b210b5b33dd9c2d5a" +sha256 = "0f0516f98aba2c506740bbbc87f10df4e5fbbe746d3c5048c801adb121cb2ea1" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a3863e2fed5ef584a87f65083a6b9fedf26d18ecebd574efce22bd7a01e3281c" +sha256 = "a56f4c67cb78766a3ab999a35a4cba69a766dd2801c001850b955a47a9be68e2" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cade94846289c2822b408a2ea23e6c8f8f321708f4f27fd563695a3c4fade6a1" +sha256 = "b7f05f2702db72970f569c76f5ad8ce603f4f0dc3b002affd30a7a00014be170" [[asset]] path = "crates/ui-egui/src/i18n/lt.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 = "35b6b03dae01b9a2e98f6d1f682d0699d77772e6efec7e28e1f26039c57b151f" +sha256 = "4897287cf606590a2c8b111ac7888162a1ecae71459dfca003631017c2cfbda3" [[asset]] path = "assets/icons/accessibility.svg" @@ -3351,4 +3351,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2685fbb694cbdeb7632ecb5e7396e1209047c83319e28ceea032b3ec9fb2fc0e" +sha256 = "b0f9ee6c4b3872821547b1387be904cf9610afff17173b62081707225bc058d5" From 89cf9b2c7cdc12591008845016f634b03e557a2e Mon Sep 17 00:00:00 2001 From: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:34:53 -0400 Subject: [PATCH 007/517] Attachments: restore the end of organize.attachments-add's parity note The note was cut at an escaped quote when it was ported, which left parity/acrobat-features.toml unparseable (cargo xtask parity failed). Co-Authored-By: Claude Opus 5.5 --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e8999e88e8..90d9c8ef67 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3329,7 +3329,7 @@ status = "shipped" commands = ["edit.attach"] tools = ["attachment_add"] evidence = ["crates/organize/src/tests.rs::attachments_are_added_listed_and_deleted", "crates/organize/src/tests.rs::attachments_added_in_the_app_survive_combine_and_more_can_be_attached", "crates/automation/tests/automation.rs::attaching_and_deleting_files_through_the_tools"] -notes = "Edit ▸ Attach a file… (also the Attach a file… button in the Attachments panel and the Edit a PDF list): the file goes into /Names /EmbeddedFiles as a Filespec with a Flate-compressed EmbeddedFile stream (/Size, /ModDate, /Subtype from the extension); a name already taken gets \" +notes = "Edit ▸ Attach a file… (also the Attach a file… button in the Attachments panel and the Edit a PDF list): the file goes into /Names /EmbeddedFiles as a Filespec with a Flate-compressed EmbeddedFile stream (/Size, /ModDate, /Subtype from the extension); a name already taken gets \" (2)\"…; the panel opens on it. Undoable. Acrobat's Comment ▸ Attach File (a paper-clip comment on the page) is comment.attach-file-comment." [[feature]] id = "organize.attachments-delete" From ff62683ff80f90d520f0ac4439d4b34ed2b2956b Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:18:58 +0300 Subject: [PATCH 008/517] M12: skip PDF container scripts during active XFA layout (#2589) --- ROADMAP.md | 3 ++ crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 29 +++++++++++ crates/engine/README.md | 8 +++ crates/engine/src/js.rs | 8 ++- crates/engine/src/tests.rs | 72 +++++++++++++++++++++++++++ crates/xfa/README.md | 5 +- docs/roadmap.md | 6 ++- parity/acrobat-features.toml | 4 +- 9 files changed, 130 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fe9723fdf6..0b48c519ea 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-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2589: active XFA skips PDF-container script initialization; requested messages and static/ordinary PDF scripts remain; 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; 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) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -156,6 +157,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-15 (M12, #2589):** Active XFA layout no longer initializes the Acrobat console/field context with PDF-container document scripts, avoiding misleading fallback-viewer alerts. Scripts remain inspectable and saved; explicit console/field and XFA messages remain, as do ordinary/static PDF helpers. Synthetic engine and tool tests cover save/reopen and static-conversion undo. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). @@ -490,6 +492,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M12 #2589: active XFA skips PDF-container script initialization; requested messages and static/ordinary PDF scripts remain; 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. | | 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. | diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 5e89aef78a..76e34de248 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1041,7 +1041,7 @@ pub fn tools() -> Vec { t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") .cmd("form.merge_data") .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), - t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.") + t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.") .cmd("tools.js_console") .with(schema( json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f38..9ab1292eb9 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5434,6 +5434,35 @@ fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() { assert_eq!(r["alerts"], json!(["Hello 4"])); } +#[test] +fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() { + let dir = workdir("xfa-container-scripts"); + std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "form.pdf"}))["doc"].as_u64().unwrap(); + let fallback = "app.alert('This PDF form requires a newer version of Adobe Acrobat');"; + ok(&mut a, "js_set_document_script", json!({"doc":doc,"name":"viewerFallback","script":fallback})); + let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"app.alert('Console message');"})); + assert!(out["error"].is_null(), "{out}"); + assert_eq!(out["alerts"], json!(["Console message"])); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"})); + ok(&mut a, "doc_close", json!({"doc":doc})); + let doc = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "js_document_scripts", json!({"doc":doc}))["scripts"], json!([{"name":"viewerFallback","script":fallback}])); + let out = ok(&mut a, "js_run", json!({"doc":doc,"script":""})); + assert!(out["error"].is_null(), "{out}"); + assert_eq!(out["alerts"], json!([])); + let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"","field":"hello"})); + assert!(out["error"].is_null(), "{out}"); + assert_eq!(out["alerts"], json!(["Hello 2"])); + ok(&mut a, "xfa_to_static", json!({"doc":doc})); + let out = ok(&mut a, "js_run", json!({"doc":doc,"script":""})); + assert_eq!(out["alerts"].as_array().unwrap().len(), 1); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(ok(&mut a, "js_run", json!({"doc":doc,"script":""}))["alerts"], json!([])); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off() { let dir = workdir("xfa-scripts-off"); diff --git a/crates/engine/README.md b/crates/engine/README.md index 4bf80e3f73..86492bc043 100644 --- a/crates/engine/README.md +++ b/crates/engine/README.md @@ -16,6 +16,14 @@ Two frontends exist on top of it today: `pdfcraft-ui-egui` (desktop and web) and `pdfcraft-automation` (headless tools, the CLI and MCP). Anything one can do, the other must be able to do — see AGENTS.md §3. +When dynamic XFA layout is active, `JsRunner` does not initialize Acrobat JavaScript with +scripts from the PDF container's `/Names /JavaScript` tree. Those scripts belong to the +fallback PDF view and can otherwise show a misleading viewer-compatibility warning. +They remain in the document and in `Document::document_scripts`. Requested console scripts, +field actions and XFA events can still show messages; ordinary PDFs, static XFA and shells +without an active layout keep their document scripts. Converting XFA to a static form and +undoing that conversion switches the context with the document state. + ## The editing model Each open document holds a `pdfcraft_cos::Document`: the object graph plus a copy-on-write overlay. diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 8958688d2d..0497594e1e 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -113,7 +113,13 @@ pub struct JsRunner { impl JsRunner { pub fn new(cos: &pdfcraft_cos::Document, file_name: &str) -> JsRunner { let doc = pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos) }; - JsRunner { doc, doc_scripts: pdfcraft_forms::document_scripts(cos), output: JsOutput::default(), cos: cos.clone() } + // Once XFA owns the form, the PDF container's scripts belong to its fallback view. + // Keep them in the file and in Document JavaScripts, but do not initialize an + // AcroForm context with them: a viewer-compatibility alert would be misleading. + // Packets alone are not enough: static XFA and an unlaid-out shell keep their scripts. + let xfa_active = pdfcraft_xfa::xfa_shape(cos).is_some() && pdfcraft_xfa::existing_layout(cos).is_some(); + let doc_scripts = if xfa_active { Vec::new() } else { pdfcraft_forms::document_scripts(cos) }; + JsRunner { doc, doc_scripts, output: JsOutput::default(), cos: cos.clone() } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed90eea411..f8e898564e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3035,6 +3035,78 @@ fn form_javascript_validates_calculates_and_formats() { assert!(s.run_javascript(id, "1", None).is_err()); } +#[test] +fn active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts() { + let template = pdfcraft_xfa::fixtures::scripted_template().replace( + r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, + r#"xfa.host.messageBox("Hello " + qty.rawValue); xfa.host.print(1, "0", "0", 0, 0, 0, 0, 0);"#, + ); + let mut cos = pdfcraft_cos::Document::open(Arc::new(pdfcraft_xfa::fixtures::shell(&template))).unwrap(); + let fallback = "app.alert('This PDF form requires a newer version of Adobe Acrobat');"; + pdfcraft_forms::set_document_script(&mut cos, "viewerFallback", Some(fallback)).unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("xfa.pdf", None, bytes, None).unwrap(); + assert!(s.get(id).unwrap().xfa.is_some()); + assert!(s.take_js_output(id).alerts.is_empty()); + let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap(); + assert!(out.error.is_none(), "{out:?}"); + assert_eq!(out.alerts, ["Console message"]); + assert_eq!(s.get(id).unwrap().document_scripts(), [("viewerFallback".into(), fallback.into())]); + let out = s.run_javascript(id, "", Some("hello")).unwrap(); + assert!(out.error.is_none(), "{out:?}"); + assert_eq!(out.alerts, ["Hello 2"]); + assert_eq!(out.requests, [js::Request::Print]); + // The same context rule applies to format/validate/calculate through JsRunner. + s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "validate".into(), script: Some("app.alert('Field message');".into()) }).unwrap(); + s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap(); + assert_eq!(s.take_js_output(id).alerts, ["Field message"]); + // Saved XFA keeps its container scripts for inspection, but does not execute them. + let saved = s.get(id).unwrap().bytes.clone(); + let reopened = s.open("saved.pdf", None, saved, None).unwrap(); + assert!(s.run_javascript(reopened, "", None).unwrap().alerts.is_empty()); + // Making the form static restores the ordinary PDF context; undo restores XFA's. + s.apply(id, Edit::XfaToStatic).unwrap(); + assert_eq!(s.run_javascript(id, "", None).unwrap().alerts.len(), 1); + s.undo(id).unwrap(); + assert!(s.run_javascript(id, "", None).unwrap().alerts.is_empty()); +} + +#[test] +fn active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts() { + let template = r#""#; + let mut s = Session::new(); + let id = s.open("no-scripts.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(template)), None).unwrap(); + assert!(s.get(id).unwrap().xfa.is_some()); + assert!(s.get(id).unwrap().xfa_template.is_none()); + s.apply(id, Edit::SetDocumentScript { name: "fallback".into(), script: Some("throw new Error('PDF fallback ran');".into()) }).unwrap(); + let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap(); + assert!(out.error.is_none(), "{out:?}"); + assert_eq!(out.alerts, ["Console message"]); +} + +#[test] +fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() { + for bytes in [ + fixture(1), + pdfcraft_xfa::fixtures::static_shell("Test"), + pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()), + ] { + let mut cos = pdfcraft_cos::Document::open(Arc::new(bytes)).unwrap(); + pdfcraft_forms::set_document_script(&mut cos, "helper", Some("function twice(n) { return n * 2; } app.alert('PDF message');")).unwrap(); + // The raw dynamic shell has not been laid out: XFA's presence alone is not enough. + let runner = js::JsRunner::new(&cos, "control.pdf"); + let out = pdfcraft_js::run("twice(3)", &pdfcraft_js::Event::doc("Console"), &runner.doc, &[], &runner.doc_scripts, Default::default()); + assert!(out.error.is_none(), "{out:?}"); + assert_eq!(out.result.as_deref(), Some("6")); + assert_eq!(out.alerts, ["PDF message"]); + } +} + #[test] fn edit_history_does_not_replay_field_script_side_effects() { let mut s = Session::new().with_clock(|| 1_700_000_000); diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 5d494d7b44..0d4cdf9ed1 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -59,7 +59,10 @@ be measured exponentially often in their nesting. A measurement budget backs thi the datasets packet per event), and `rerender`, which lays the form out again from template, data and overrides. The scripts themselves run in the `js` crate's XFA object model; the engine runs initialize and calculate on open, change, exit, validate and calculate after a - field changes, and click for buttons. + field changes, and click for buttons. While this XFA layout is active, the engine does not + initialize Acrobat JavaScript with the PDF container's document-level scripts (including + non-XFA-viewer warnings). They stay available for inspection and saving; explicit console + scripts and XFA messages still run. Static XFA keeps its document scripts. Private keys PdfCraft writes (other viewers ignore them): diff --git a/docs/roadmap.md b/docs/roadmap.md index 3148823ea5..7a4b52c866 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2589: active XFA skips PDF-container script initialization; requested messages and static/ordinary PDF scripts remain; 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) @@ -128,9 +129,9 @@ total matches the parity estimate. | 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 | | 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. 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 | +| 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). Active XFA skips PDF-container fallback scripts (#2589), preserving requested messages and saved scripts.
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 | +| 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. Active XFA skips PDF-container fallback scripts (#2589), preserving requested messages and saved scripts.
Missing: mixed-content and field autotag, heading/list/table/figure classification, 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. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). | @@ -179,6 +180,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M12 #2589: active XFA skips PDF-container script initialization; requested messages and static/ordinary PDF scripts remain; 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. | | 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 533013b547..cdce9fb60d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5220,8 +5220,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"] -notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen"] +notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From 4f0493cc21e512087f6e625a5ddbd5426b777d21 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:09:53 +0300 Subject: [PATCH 009/517] organize: split a combined field/widget so every copied page owns its widget --- crates/organize/src/import.rs | 90 ++++++++++++++++++++++++++++------- crates/organize/src/tests.rs | 64 ++++++++++++++++++++----- 2 files changed, 125 insertions(+), 29 deletions(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 588539866a..ef18a121f0 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -34,6 +34,13 @@ const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"]; /// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). const MAX_COPY_DEPTH: usize = 256; +/// Keys that stay on the widget when a combined field/widget is split (#1810): the annotation's +/// appearance, placement and page. Field state (name, value, flags, default appearance) moves to +/// the new parent field, where the widgets inherit it. +const WIDGET_KEYS: &[&[u8]] = &[ + b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"BS", b"Border", b"C", b"CA", b"F", b"H", b"R", b"NM", b"M", b"StructParent", b"StructParents", b"OC", b"Tabs", +]; + struct Copier<'a> { src: &'a Document, /// Current recursion depth, bounded by [`MAX_COPY_DEPTH`]. @@ -393,27 +400,32 @@ fn import_pages_mapped( let r = dst.add(Object::Null); let c = copier.copy_value(dst, &resolved, Some(r))?; dst.set(r, c); - // `/Kids` may be an array object of its own: add to it there, so the - // field keeps its other widgets. - match dst.get(copied).as_dict().and_then(|f| f.get(b"Kids").cloned()) { - Some(Object::Ref(list)) => { - let mut kids = dst.get(list).as_array().cloned().unwrap_or_default(); - kids.push(Object::Ref(r)); - dst.set(list, Object::Array(kids)); - } - _ => dst.update_dict(copied, |f| { - let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default(); - kids.push(Object::Ref(r)); - f.set(b"Kids".to_vec(), Object::Array(kids)); - })?, - } + add_kid(dst, copied, r)?; annot_pages.insert(r, new); out.push(Object::Ref(r)); continue; } - // A widget that is its own field (no parent to hang a second widget on) stays - // the one object, shown on both pages, so the field stays one. + // A widget that is its own field (no parent to hang a second widget on): on + // its first copy it is split into a parent field and a widget child (#1810); + // every later copy of the page gets one more widget child of that field, so + // the pages no longer share one widget whose `/P` names only the last page. let own_field = resolved.as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent")); + if own_field + && let Some(first) = a.as_ref().and_then(|r| copier.map.get(&r).copied()) + && let Some(parent) = dst.get(first).as_dict().and_then(|d| d.reference(b"Parent")) + { + let r = dst.add(Object::Null); + let c = copier.copy_value(dst, &resolved, Some(r))?; + let Object::Dict(mut d) = c else { continue }; + d.set(b"Parent".to_vec(), Object::Ref(parent)); + // The new widget is not a field root of its own. + copier.fields.retain(|f| *f != r); + dst.set(r, Object::Dict(d)); + add_kid(dst, parent, r)?; + annot_pages.insert(r, new); + out.push(Object::Ref(r)); + continue; + } let annots = if own_field { &mut copier } else { own.as_mut().unwrap_or(&mut copier) }; // Direct (inline) annotation dictionaries become indirect objects, so every // copied annotation can be fixed up the same way. @@ -427,6 +439,9 @@ fn import_pages_mapped( } _ => continue, }; + if own_field && copier.fields.contains(&r) { + split_own_field(dst, &mut copier.fields, r); + } annot_pages.insert(r, new); out.push(Object::Ref(r)); } @@ -805,6 +820,49 @@ fn qualified_optional(prefix: &str, name: &str) -> String { if name.is_empty() { prefix.to_string() } else { qualified(prefix, name) } } +/// Add `kid` to `field`'s `/Kids`, in the array object itself when `/Kids` is one, so the field +/// keeps its other widgets. +fn add_kid(dst: &mut Document, field: ObjRef, kid: ObjRef) -> Result<(), OrganizeError> { + match dst.get(field).as_dict().and_then(|f| f.get(b"Kids").cloned()) { + Some(Object::Ref(list)) => { + let mut kids = dst.get(list).as_array().cloned().unwrap_or_default(); + kids.push(Object::Ref(kid)); + dst.set(list, Object::Array(kids)); + } + _ => dst.update_dict(field, |f| { + let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default(); + kids.push(Object::Ref(kid)); + f.set(b"Kids".to_vec(), Object::Array(kids)); + })?, + } + Ok(()) +} + +/// PdfCraft patch (#1810): split a widget that doubles as its own field into a parent field +/// holding the field keys (name, value, flags…) and a widget child holding the annotation keys +/// (rectangle, appearance, page). The parent takes the widget's place among the top-level fields; +/// later copies of the page add widget children of it, so every page has its own widget and the +/// field stays one. +fn split_own_field(dst: &mut Document, fields: &mut [ObjRef], widget: ObjRef) { + let Some(d) = dst.get(widget).as_dict().cloned() else { return }; + let (mut field, mut child) = (Dict::new(), Dict::new()); + for (k, v) in d.iter() { + if WIDGET_KEYS.contains(&k.as_slice()) { + child.set(k.clone(), v.clone()); + } else { + field.set(k.clone(), v.clone()); + } + } + let parent = dst.add(Object::Null); + field.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(widget)])); + child.set(b"Parent".to_vec(), Object::Ref(parent)); + dst.set(parent, Object::Dict(field)); + dst.set(widget, Object::Dict(child)); + if let Some(entry) = fields.iter_mut().find(|f| **f == widget) { + *entry = parent; + } +} + /// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed). fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> { if fields.is_empty() { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index d34b1e431d..c1e20a1327 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -672,9 +672,40 @@ fn links_are_rewired_to_copied_pages_or_dropped() { assert!(!link.contains(b"Dest") && !link.contains(b"A")); } -/// A widget annotation that is its own form field (no `/Parent`). -fn own_field(doc: &Document, r: ObjRef) -> bool { - doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent")) +/// Issue #1810: a widget that is its own field was shared between both copies of a repeated +/// page, so only the last page could identify it (its `/P`), and per-page annotation edits were +/// impossible. Each copy must get its own widget sharing one parent field (name and value). +#[test] +fn repeated_page_splits_a_combined_field_widget() { + let out = full_roundtrip(&extract_pages(&doc_a(), &[1, 1]).unwrap()); + let ps = pages(&out).unwrap(); + let widget = |i: usize| -> ObjRef { + let list = page_dict(&out, i).get(b"Annots").map(|a| out.resolve(a).as_array().cloned().unwrap_or_default()).unwrap_or_default(); + list.iter().filter_map(|a| a.as_ref()).find(|r| out.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))).expect("a widget annotation") + }; + let (w1, w2) = (widget(0), widget(1)); + assert_ne!(w1, w2, "each copy of the page has its own widget"); + for (i, w) in [(0, w1), (1, w2)] { + let d = out.get(w).as_dict().unwrap().clone(); + assert_eq!(d.reference(b"P"), Some(ps[i].obj), "/P names the page holding the widget"); + let parent = d.reference(b"Parent").expect("the widget has a parent field"); + let f = out.get(parent).as_dict().unwrap().clone(); + assert_eq!(f.get(b"T").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("name")); + assert_eq!(f.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("Ada")); + let kids: Vec = f.get(b"Kids").and_then(|k| out.resolve(k).as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); + assert!(kids.contains(&w1) && kids.contains(&w2), "one field holds both widgets: {kids:?}"); + let fields: Vec = catalog(&out) + .get(b"AcroForm") + .map(|f| out.resolve(f)) + .and_then(|f| f.as_dict().cloned()) + .and_then(|f| f.get(b"Fields").map(|x| out.resolve(x))) + .and_then(|f| f.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|f| f.as_ref()) + .collect(); + assert_eq!(fields, [parent], "the parent is the one top-level field"); + } } #[test] @@ -690,15 +721,11 @@ fn repeated_page_gets_independent_annotations() { assert!(!refs[i].is_empty()); assert_eq!(refs[i].len(), refs[i + 1].len()); for (a, b) in refs[i].iter().zip(&refs[i + 1]) { - // A widget that is its own field stays one object on both pages, so the field stays - // one (#790); every other annotation is copied. - if !own_field(&out, *a) { - assert_ne!(a, b, "each copy of the page has its own annotation objects"); - } + assert_ne!(a, b, "each copy of the page has its own annotation objects"); } } for (i, list) in refs.iter().enumerate() { - for a in list.iter().filter(|a| !own_field(&out, **a)) { + for a in list.iter() { assert_eq!(out.get(*a).as_dict().unwrap().reference(b"P"), Some(ps[i].obj), "/P names the page holding the copy"); } } @@ -719,7 +746,9 @@ fn form_fields_come_along_and_are_registered() { assert_eq!(fields.len(), 1); let field = out.resolve(&fields[0]).as_dict().cloned().unwrap(); assert_eq!(field.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("Ada")); - assert_eq!(field.reference(b"P"), Some(pages(&out).unwrap()[0].obj)); + // The widget child, not the field, names the page (#1810). + let kid = field.get(b"Kids").map(|k| out.resolve(k)).and_then(|k| k.as_array().cloned()).and_then(|k| k.first().and_then(|k| k.as_ref())).unwrap(); + assert_eq!(out.get(kid).as_dict().unwrap().reference(b"P"), Some(pages(&out).unwrap()[0].obj)); } #[test] @@ -2547,8 +2576,9 @@ fn import_renames_a_field_colliding_with_another_incoming_field() { assert_eq!(form_field_names(&dst), ["Other", "Text1", "Text1-1"]); } -/// A page listed twice shows its widget on both copies — also when the field was renamed: both -/// pages' widgets belong to the one renamed field, and no second Text1 appears. +/// A page listed twice shows its widget on both copies — also when the field was renamed: each +/// copy has its own widget, both widgets belong to the one renamed field (#1810), and no second +/// Text1 appears. #[test] fn a_repeated_page_joins_the_renamed_field() { let mut dst = doc_with_field("Text1"); @@ -2565,10 +2595,18 @@ fn a_repeated_page_joins_the_renamed_field() { .and_then(|l| l.as_array().cloned()) .unwrap(); let renamed = fields[1].clone(); + let mut widgets = Vec::new(); for page in [1, 2] { let list = page_dict(&dst, page).get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap(); - assert!(list.contains(&renamed), "page {page}'s widget belongs to the renamed field"); + let widget = list.iter().find_map(|a| a.as_ref()).expect("a widget on the page"); + widgets.push(widget); + assert_eq!( + dst.get(widget).as_dict().unwrap().reference(b"Parent"), + renamed.as_ref(), + "page {page}'s widget belongs to the renamed field" + ); } + assert_ne!(widgets[0], widgets[1], "each copy has its own widget"); } /// Combining forms keeps same-named fields separate — the second file's colliding field is From 27a2ac47cffcb1fdb9d43b5a30657250b6164f9e Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Fri, 16 Oct 2026 16:04:41 +0200 Subject: [PATCH 010/517] fix(ui): the header, footer and watermark previews show the page under the marks (#2837) The Header and Footer, Watermark and Background dialogs drew their preview on a blank sheet, so you could not see where the marks would land on the page's text and pictures. The preview now paints the page's own thumbnail, rendered off the UI thread like the pages panel's, with a blank sheet until it arrives. It shows the current page when the page range selects it, otherwise the first page the range selects, at its displayed size (after /Rotate), which is the frame the engine lays the marks out in. A background colour tints the page picture instead of covering it, as a background behind the content looks. Fixes #2837. --- crates/ui-egui/src/marks_ui.rs | 85 +++++++++++++--- crates/ui-egui/tests/ui_group/marks.rs | 130 ++++++++++++++++++++++++- parity/acrobat-features.toml | 14 +-- 3 files changed, 207 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index 4a60c40a6e..e994c79118 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -1,6 +1,7 @@ //! Add Header and Footer, Add Watermark and Add Background dialogs (Acrobat: Edit a PDF ▸ …; -//! dialog snapshots d13, d14, d12d). Each has the shared Page Range Options and a schematic -//! preview of the current page. "Update" opens the same dialog in replace mode. +//! dialog snapshots d13, d14, d12d). Each has the shared Page Range Options and a preview of a +//! page the range selects, with the marks laid out on it. "Update" opens the same dialog in +//! replace mode. use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2}; use pdfcraft_engine::{Background, Edit, HeaderFooter, MarkKind, Watermark}; @@ -123,14 +124,30 @@ fn color_button(ui: &mut egui::Ui, c: &mut [f64; 3]) { } } -/// A schematic of the current page with the marks laid out (no rendering needed). -fn preview(ui: &mut egui::Ui, t: &Tokens, size: (f64, f64), draw: impl FnOnce(&egui::Painter, Rect, f32)) { +/// The page (displayed size `size`) with the marks laid out on it. `picture` is its thumbnail, +/// rendered off the UI thread like the pages panel's; until it arrives the page is a blank sheet. +/// `tint` multiplies the page, which is how a background behind its content looks. +fn preview( + ui: &mut egui::Ui, + t: &Tokens, + size: (f64, f64), + picture: Option, + tint: Color32, + draw: impl FnOnce(&egui::Painter, Rect, f32), +) { let (w, h) = (size.0.max(1.0), size.1.max(1.0)); let s = (190.0 / w.max(h)) as f32; let (r, _) = ui.allocate_exact_size(vec2(210.0, 210.0), egui::Sense::hover()); let page = Rect::from_center_size(r.center(), vec2(w as f32 * s, h as f32 * s)); let painter = ui.painter_at(r); - painter.rect_filled(page, CornerRadius::ZERO, Color32::WHITE); + match picture { + Some(tex) => { + painter.image(tex, page, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint); + } + None => { + painter.rect_filled(page, CornerRadius::ZERO, tint); + } + } painter.rect_stroke(page, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); draw(&painter, page, s); } @@ -140,13 +157,26 @@ fn rgb32(c: [f64; 3]) -> Color32 { Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) } +/// The tint of a page over a background of `color` at `opacity`: the colour shows where the page +/// is white and the content stays on top. A non-finite opacity is opaque, as the engine has it. +fn behind(color: [f64; 3], opacity: f64) -> Color32 { + let a = if opacity.is_finite() { opacity.clamp(0.0, 1.0) } else { 1.0 }; + rgb32(color.map(|c| 1.0 - a * (1.0 - c))) +} + +/// The page (0-based) the preview shows: the current one when the range selects it, otherwise the +/// first page the range selects (the current one again when it selects none). +fn preview_page(range: &PageRange, current: usize, count: usize) -> usize { + let pages = range.pages(count); + if pages.contains(¤t) { current } else { pages.first().copied().unwrap_or(current) } +} + /// The dialog for `kind`; returns (apply, cancel). pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: MarkKind) -> (bool, bool) { let Some((i, id)) = app.active_ids() else { return (false, true) }; let current = app.views[i].current; let Some(doc) = app.session.get(id) else { return (false, true) }; let count = doc.info.pages.len(); - let page = doc.info.pages.get(current).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0)); let units = app.units; let d = &mut app.marks_draft; let mut browse = false; @@ -305,12 +335,21 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ui.label(egui::RichText::new(tl!("Page Range Options")).font(theme::semibold(12.5))); d.range.ui(ui, count); ui.add_space(8.0); - // Preview of the current page. + // The preview: the current page, or the first one the range selects if it leaves that out, + // drawn from its thumbnail at its displayed size (after /Rotate), the frame the engine lays + // the marks out in. + let target = preview_page(&d.range, current, count); + let page = doc.info.pages.get(target).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0)); + let picture = app.views.get_mut(i).and_then(|view| { + view.need_thumbnail(target, true); + view.thumb_id(target) + }); + let tint = if kind == MarkKind::Background && !d.use_file { behind(d.bg.color, d.bg.opacity) } else { Color32::WHITE }; let hf = d.hf.clone(); - let (wm, bg, fit) = (d.wm.clone(), d.bg.clone(), d.fit); + let (wm, fit) = (d.wm.clone(), d.fit); let file_name = d.use_file.then(|| d.file.as_ref().map(|f| f.0.clone())).flatten(); ui.horizontal(|ui| { - preview(ui, t, page, |p, r, s| match kind { + preview(ui, t, page, picture, tint, |p, r, s| match kind { MarkKind::HeaderFooter => { for (k, text) in hf.text.iter().enumerate() { if text.trim().is_empty() { @@ -362,15 +401,14 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M Color32::GRAY, ); } - MarkKind::Background => { - p.rect_filled(r, CornerRadius::ZERO, rgb32(bg.color).gamma_multiply(bg.opacity as f32)); - } + // A colour is the page's tint. + MarkKind::Background => {} }); ui.vertical(|ui| { ui.label( egui::RichText::new(crate::i18n::fmt( tl!("Preview: page {p} of {n}"), - &[("p", &(current + 1).to_string()), ("n", &count.to_string())], + &[("p", &(target + 1).to_string()), ("n", &count.to_string())], )) .small() .color(t.text_faint), @@ -521,6 +559,27 @@ mod tests { assert_eq!(r.pages(5), [0, 2, 4]); } + #[test] + fn the_preview_shows_a_page_the_range_selects() { + let all = PageRange::default(); + assert_eq!(preview_page(&all, 2, 5), 2, "the current page when the range selects it"); + let even = PageRange { all: true, subset: Subset::Even, ..PageRange::default() }; + assert_eq!(preview_page(&even, 0, 5), 1, "otherwise the first page the range selects"); + let later = PageRange { all: false, from: 3, to: 4, subset: Subset::All }; + assert_eq!(preview_page(&later, 0, 5), 2); + let none = PageRange { all: false, from: 4, to: 2, subset: Subset::All }; + assert_eq!(preview_page(&none, 1, 5), 1, "the current page when the range selects none"); + assert_eq!(preview_page(&all, 0, 0), 0, "no pages at all"); + } + + #[test] + fn a_background_tints_the_page_it_is_behind() { + assert_eq!(behind([1.0, 0.0, 0.0], 1.0), Color32::from_rgb(255, 0, 0)); + assert_eq!(behind([1.0, 0.0, 0.0], 0.0), Color32::WHITE); + assert_eq!(behind([0.0, 0.0, 0.0], 0.5), Color32::from_rgb(128, 128, 128)); + assert_eq!(behind([0.2, 0.4, 0.6], f64::NAN), behind([0.2, 0.4, 0.6], 1.0), "opaque, as the engine has it"); + } + #[test] fn the_watermark_edit_carries_the_offset() { let mut d = MarksDraft::default(); diff --git a/crates/ui-egui/tests/ui_group/marks.rs b/crates/ui-egui/tests/ui_group/marks.rs index 0a0e45b152..fa5d7f884d 100644 --- a/crates/ui-egui/tests/ui_group/marks.rs +++ b/crates/ui-egui/tests/ui_group/marks.rs @@ -15,10 +15,14 @@ trailer << /Root 1 0 R >> %%EOF"; fn harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + harness_with(FIXTURE.to_vec()) +} + +fn harness_with(pdf: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap(); + app.open_bytes("doc.pdf", None, pdf.clone()).unwrap(); app }); h.run_steps(4); @@ -96,3 +100,125 @@ fn backgrounds_and_watermarks_from_a_file() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Add background")); } + +/// Two 300 × 400 pages with content (a dark bar), the second turned a quarter (/Rotate 90), so it +/// is shown landscape, 400 × 300. +fn content_fixture() -> Vec { + let bar = "0 0 0 rg 40 300 220 60 re f"; + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Contents 5 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Rotate 90 /Contents 5 0 R >> endobj +5 0 obj << /Length {} >> +stream +{bar} +endstream +endobj +trailer << /Root 1 0 R >> +%%EOF", + bar.len() + ) + .into_bytes() +} + +/// The pictures of page `page` (its thumbnail) painted last frame: paint order, bounds, tint. +fn page_pictures(h: &Harness<'static, PdfCraftApp>, page: usize) -> Vec<(usize, egui::Rect, egui::Color32)> { + let Some(tex) = h.state().views[0].thumb(page).map(|t| t.id()) else { return Vec::new() }; + h.output() + .shapes + .iter() + .enumerate() + .filter_map(|(k, s)| match &s.shape { + egui::Shape::Mesh(m) if m.texture_id == tex => Some((k, m.calc_bounds(), m.vertices.first()?.color)), + _ => None, + }) + .collect() +} + +/// The preview's picture of `page`, once its thumbnail has arrived (it renders off the UI thread). +fn wait_for_picture(h: &mut Harness<'static, PdfCraftApp>, page: usize) -> (usize, egui::Rect, egui::Color32) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + h.step(); + if let [one] = page_pictures(h, page)[..] { + return one; + } + assert!(std::time::Instant::now() < deadline, "the preview never showed page {}'s picture", page + 1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +/// A text painted on the page last frame (the dialog's text boxes show it too): its paint order +/// and its laid-out rectangle. +fn painted(h: &Harness<'static, PdfCraftApp>, text: &str, page: egui::Rect) -> (usize, egui::Rect) { + h.output() + .shapes + .iter() + .enumerate() + .find_map(|(k, s)| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == text => { + Some((k, t.galley.rect.translate(t.pos.to_vec2()))).filter(|(_, r)| page.contains(r.center())) + } + _ => None, + }) + .unwrap_or_else(|| panic!("{text:?} is painted on the page")) +} + +fn near(a: f32, b: f32, what: &str) { + assert!((a - b).abs() < 1.0, "{what}: {a} against {b}"); +} + +#[test] +fn the_preview_shows_the_page_the_range_selects_under_the_marks() { + use pdfcraft_ui_egui::marks::{PageRange, Subset}; + let mut h = harness_with(content_fixture()); + assert!(h.state_mut().execute("edit.header_footer")); + h.run_steps(2); + { + let d = &mut h.state_mut().marks_draft; + d.hf.text[1] = "TOP".into(); + d.hf.text[5] = "END".into(); + // Page 2 alone, not the current page: the preview follows the range (#2837). + d.range = PageRange { all: false, from: 2, to: 2, subset: Subset::All }; + } + let (at, page, tint) = wait_for_picture(&mut h, 1); + h.get_by_label("Preview: page 2 of 2"); + assert_eq!(tint, egui::Color32::WHITE); + assert!((page.width() / page.height() - 4.0 / 3.0).abs() < 0.01, "page 2 is shown turned, as displayed: {page:?}"); + // The marks are drawn over the page, where the engine puts them on its displayed box + // (default margins: 36 pt top and bottom, 72 pt left and right). + let s = page.width() / 400.0; + let (top_at, top) = painted(&h, "TOP", page); + let (end_at, end) = painted(&h, "END", page); + assert!(at < top_at && at < end_at, "the page is under the marks"); + near(top.center().x, page.center().x, "centre header, across"); + near(top.center().y, page.top() + 36.0 * s, "centre header, down"); + near(end.right(), page.right() - 72.0 * s, "right footer, across"); + near(end.center().y, page.bottom() - 36.0 * s, "right footer, down"); + h.get_by_label("Cancel").click(); + h.run_steps(2); + + // The watermark sits on the page's centre. + assert!(h.state_mut().execute("edit.watermark")); + h.run_steps(2); + { + let d = &mut h.state_mut().marks_draft; + d.wm.text = "DRAFT".into(); + d.wm.rotation = 0.0; + } + let (at, page, _) = wait_for_picture(&mut h, 1); + let (mark_at, mark) = painted(&h, "DRAFT", page); + assert!(at < mark_at, "the page is under the watermark"); + near(mark.center().x, page.center().x, "watermark, across"); + near(mark.center().y, page.center().y, "watermark, down"); + h.get_by_label("Cancel").click(); + h.run_steps(2); + + // A background colour shows where the page is white: it tints the page's picture. + assert!(h.state_mut().execute("edit.background")); + h.run_steps(2); + let (_, _, tint) = wait_for_picture(&mut h, 1); + assert_eq!(tint, egui::Color32::from_rgb(255, 255, 217), "the default pale yellow, opaque"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94f..c5bb50420e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2388,8 +2388,8 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer"] tools = ["doc_header_footer"] -evidence = ["crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"] -notes = "Helvetica only (no font choice yet); drawn in display space on rotated pages; artifact-tagged" +evidence = ["crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/ui-egui/tests/ui_group/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] +notes = "Helvetica only (no font choice yet); drawn in display space on rotated pages; artifact-tagged; the dialog previews the text over the page's own thumbnail (the current page, or the first one the range selects), in the interface font and with tokens unexpanded" [[feature]] id = "edit.header-footer-update-remove" @@ -2448,8 +2448,8 @@ milestone = "M7" status = "shipped" commands = ["edit.watermark"] tools = ["doc_watermark"] -evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/ui_group/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/automation.rs::headers_watermarks_and_backgrounds_through_tools"] -notes = "Helvetica; fit-to-page or a size" +evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/ui_group/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/automation.rs::headers_watermarks_and_backgrounds_through_tools", "crates/ui-egui/tests/ui_group/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] +notes = "Helvetica; fit-to-page or a size; the dialog previews it over the page's own thumbnail, always on top (a watermark behind the page is not drawn under its content there)" [[feature]] id = "edit.watermark-add-file" @@ -2461,7 +2461,7 @@ status = "shipped" commands = ["edit.watermark"] tools = ["doc_watermark"] evidence = ["crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files"] -notes = "an image or a page of a PDF, scaled relative to the page, rotated, with opacity, behind or on top" +notes = "an image or a page of a PDF, scaled relative to the page, rotated, with opacity, behind or on top; the dialog's preview shows its frame and file name over the page, not the picture itself" [[feature]] id = "edit.watermark-update-remove" @@ -2496,8 +2496,8 @@ milestone = "M7" status = "shipped" commands = ["edit.background"] tools = ["doc_background"] -evidence = ["crates/ui-egui/tests/ui_group/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/ui_group/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright"] -notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page" +evidence = ["crates/ui-egui/tests/ui_group/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/ui_group/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright", "crates/ui-egui/tests/ui_group/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] +notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page; the dialog previews a colour as a tint of the page's own thumbnail (opaque pictures on the page are tinted there too, not in the result), a file as its frame and name" [[feature]] id = "edit.background-update-remove" From 359a3f2f09445a436f463f786101499e8d21fadb Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Fri, 16 Oct 2026 17:28:19 +0200 Subject: [PATCH 011/517] M11: Optimize keeps a JPEG's colour transform (#1201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimize decoded DCT images with the generic JPEG decoder, which converts three components from YCbCr to RGB whenever the JPEG itself doesn't say otherwise, and then dropped /DecodeParms. An RGB JPEG stored untransformed (/ColorTransform 0 and no Adobe marker) therefore came out in other colours when recompressed, resampled or merged from strips. The samples are now read with the colour transform ISO 32000-2 §7.4.8 gives them: an Adobe APP14 marker's flag, else /ColorTransform (default 1). Untransformed samples are taken as stored, and the result is written as a plain YCbCr JPEG (or Flate), which needs no /DecodeParms. When the marker and /ColorTransform disagree, a value is out of range, the parameters are not paired with the filter as §7.4 pairs them, or the decoder reads the components otherwise (named R, G and B), the image is left as it is. The tests draw each image with PdfCraft's renderer before and after Optimize: RGB JPEGs with and without an Adobe marker under each /ColorTransform, CMYK JPEGs (still left alone) and JPEG strips merged into one picture. Part of #1201. --- Cargo.lock | 1 + ROADMAP.md | 1 + crates/optimize/Cargo.toml | 2 + crates/optimize/README.md | 9 +- crates/optimize/src/fragments.rs | 2 +- crates/optimize/src/images.rs | 146 ++++++- crates/optimize/src/lib.rs | 3 +- crates/optimize/tests/jpeg_color_transform.rs | 363 ++++++++++++++++++ parity/acrobat-features.toml | 4 +- 9 files changed, 512 insertions(+), 19 deletions(-) create mode 100644 crates/optimize/tests/jpeg_color_transform.rs diff --git a/Cargo.lock b/Cargo.lock index 91ed3c4904..0d894e46ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4224,6 +4224,7 @@ dependencies = [ "pdfcraft-model", "pdfcraft-render", "thiserror 2.0.21", + "zune-jpeg", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec04..f2cfb655ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,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-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. 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/optimize/Cargo.toml b/crates/optimize/Cargo.toml index 184ee9697b..c00bfba75c 100644 --- a/crates/optimize/Cargo.toml +++ b/crates/optimize/Cargo.toml @@ -18,6 +18,8 @@ pdfcraft-annot = { workspace = true } thiserror.workspace = true # JPEG decoding (zune-jpeg) and encoding, and bicubic resampling; already in the tree. image = { version = "0.25", default-features = false, features = ["jpeg"] } +# The same JPEG decoder, used directly to read samples untransformed (/ColorTransform 0). +zune-jpeg = "0.5" [dev-dependencies] pdfcraft-render = { workspace = true } diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 32915af6bc..10a5f4d7e7 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -25,7 +25,10 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. An image is replaced only if the result is smaller. Left alone: CMYK and other colour spaces, - image masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT. + image masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT. A JPEG is read with + its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one + stored untransformed keeps its colours; one whose marker and `/ColorTransform` disagree is left + alone, since readers differ on which wins. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded @@ -34,5 +37,5 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories, then this, then merges identical objects and writes a full, compressed save (`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a -progress bar). JPEG decoding and -encoding and resampling come from the `image` crate. +progress bar). JPEG decoding comes from `zune-jpeg` (the decoder behind `image` and the +renderer), JPEG encoding and resampling from the `image` crate. diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index b5967eee09..302c4e532c 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -157,7 +157,7 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, u32, u3 } let obj = doc.get(f.image); let Object::Stream(s) = &*obj else { return None }; - let px = pixels(s, n)?; + let px = pixels(doc, s, n)?; let px = if (f.width, f.height) == (width, r1 - r0) { px } else { resample(&px, n, f.width, f.height, width, r1 - r0)? }; out.get_mut(r0 as usize * row..r1 as usize * row)?.copy_from_slice(px.get(..(r1 - r0) as usize * row)?); } diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 8cfa82674a..1a9a918ac8 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -228,20 +228,134 @@ fn is_dct(s: &Stream) -> Option { } } -/// 8-bit samples of an image (`n` components), decoded. -pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { +/// 8-bit samples of an image (`n` components), decoded. `None` for a JPEG whose colour transform +/// readers may disagree about ([`ycc_encoded`]). +pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option> { let (w, h) = (s.dict.int(b"Width")? as usize, s.dict.int(b"Height")? as usize); 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) + // The colour transform matters to three components only (§7.4.8). CMYK and YCCK JPEGs + // never get here: the colour space check excludes them. + let ycc = n == 3 && ycc_encoded(doc, s)?; + jpeg_samples(&s.raw, w, h, n, ycc) } else { let v = s.decoded().ok()?; (v.len() >= w * h * n).then(|| v[..w * h * n].to_vec()) } } +/// Whether the samples of a three-component JPEG are YCbCr, to be converted to RGB (ISO 32000-2 +/// §7.4.8). An Adobe APP14 marker's transform flag says so, overriding the filter's +/// /ColorTransform; without a marker /ColorTransform does, 1 (YCbCr) by default. +/// +/// `None`, so the image is left as it is, when readers may disagree: the marker and +/// /ColorTransform contradict each other (PdfCraft's renderer lets /ColorTransform 0 win where +/// the standard lets the marker win), either holds a value other than 0 or 1, or the parameters +/// or the JPEG's segments can't be read. +fn ycc_encoded(doc: &Document, s: &Stream) -> Option { + let entry = color_transform(doc, s)?; + match adobe_transform(&s.raw)? { + Some(t @ (0 | 1)) => entry.is_none_or(|c| c == i64::from(t)).then_some(t == 1), + Some(_) => None, + None => match entry { + None | Some(1) => Some(true), + Some(0) => Some(false), + Some(_) => None, + }, + } +} + +/// The /ColorTransform of a JPEG stream's filter parameters: `Some(None)` without one. `None` +/// if it isn't an integer, or the parameters aren't a dictionary paired with the filter as +/// §7.4 pairs them (a dictionary for a filter name, a one-element array for a one-filter array). +fn color_transform(doc: &Document, s: &Stream) -> Option> { + let Some(parms) = s.dict.get(b"DecodeParms") else { return Some(None) }; + let parms = doc.resolve(parms); + let parms = match (s.dict.get(b"Filter"), &*parms) { + (Some(Object::Array(_)), Object::Array(a)) if a.len() == 1 => doc.resolve(a.first()?), + (Some(Object::Name(_)), _) | (_, Object::Null) => parms.clone(), + _ => return None, + }; + match &*parms { + Object::Null => Some(None), + Object::Dict(d) => match d.get(b"ColorTransform").map(|c| doc.resolve(c)).as_deref() { + None => Some(None), + Some(Object::Int(c)) => Some(Some(*c)), + Some(_) => None, + }, + _ => None, + } +} + +/// The transform flag of a JPEG's Adobe APP14 segment (0: samples stored as they are, +/// 1: YCbCr, 2: YCCK), the last one if there are several, as decoders read them; `Some(None)` +/// without one. `None` if the segments before the first scan can't be read. +fn adobe_transform(data: &[u8]) -> Option> { + if data.get(..2)? != [0xFF, 0xD8] { + return None; + } + let mut flag = None; + let mut i = 2usize; + // Every step moves on by at least one byte, and reading past the end returns. + loop { + if *data.get(i)? != 0xFF { + // Stray bytes between segments, which decoders skip. + i = i.checked_add(1)?; + continue; + } + let marker = *data.get(i.checked_add(1)?)?; + match marker { + // A fill byte before the marker. + 0xFF => i = i.checked_add(1)?, + // The first scan (or the end): no header segments after it. + 0xDA | 0xD9 => return Some(flag), + // Markers without a segment: TEM, RSTn, SOI. + 0x01 | 0xD0..=0xD8 => i = i.checked_add(2)?, + _ => { + let len = usize::from(u16::from_be_bytes([*data.get(i.checked_add(2)?)?, *data.get(i.checked_add(3)?)?])); + // The length counts its own two bytes: anything shorter is malformed. + let end = i.checked_add(2)?.checked_add(len)?; + let segment = data.get(i.checked_add(4)?..end)?; + if marker == 0xEE + && let Some(rest) = segment.strip_prefix(b"Adobe") + { + // Version, flags0 and flags1 (two bytes each), then the transform flag. + flag = Some(*rest.get(6)?); + } + i = end; + } + } + } +} + +/// 8-bit samples of a JPEG `w` × `h` with `n` components (grey or RGB): YCbCr converted to RGB +/// if `ycc`, otherwise the samples as stored. `None` if the JPEG doesn't match (size or number +/// of components), is too large, or its decoder reads the samples otherwise (for example +/// components named R, G and B, which decoders take as stored, while `ycc` says YCbCr). +fn jpeg_samples(data: &[u8], w: usize, h: usize, n: usize, ycc: bool) -> Option> { + use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + if (w.checked_mul(h)? as u64) > MAX_PIXELS { + return None; + } + let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); + let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); + dec.decode_headers().ok()?; + if dec.dimensions()? != (w, h) || usize::from(dec.info()?.components) != n { + return None; + } + let out = match (n, ycc, dec.input_colorspace()?) { + (1, _, ColorSpace::Luma) => ColorSpace::Luma, + (3, true, ColorSpace::YCbCr) => ColorSpace::RGB, + // As stored: no conversion either way. The decoder reports an Adobe marker's 0 as CMYK + // until it decodes, and then reads three such components as RGB. + (3, false, ColorSpace::YCbCr) => ColorSpace::YCbCr, + (3, false, ColorSpace::RGB | ColorSpace::CMYK) => ColorSpace::RGB, + _ => return None, + }; + dec.set_options(options.jpeg_set_out_colorspace(out)); + let px = dec.decode().ok()?; + (px.len() == w.checked_mul(h)?.checked_mul(n)?).then_some(px) +} + pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option> { use image::imageops::{FilterType, resize}; Some(match n { @@ -258,11 +372,17 @@ fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> { Some(out) } -/// Whether [`pixels`] reads `s` as it is drawn: a codec it decodes and no decode array that -/// changes anything. `None` when the colour space isn't grey or RGB. +/// Whether [`pixels`] reads `s` as it is drawn: a codec it decodes, no decode array that +/// changes anything, and for an RGB JPEG a colour transform readers agree on. `None` when the +/// colour space isn't grey or RGB. pub(crate) fn decodable(doc: &Document, s: &Stream) -> Option { let n = components(doc, s.dict.get(b"ColorSpace")?)?; - Some(is_dct(s).is_some() && s.dict.get(b"Decode").is_none_or(|d| is_identity_decode(doc, d, n))) + let dct = is_dct(s); + Some( + dct.is_some() + && s.dict.get(b"Decode").is_none_or(|d| is_identity_decode(doc, d, n)) + && (n != 3 || dct == Some(false) || ycc_encoded(doc, s).is_some()), + ) } /// Whether `decode` is the default decode array for `n` components ([0 1] each), which changes @@ -274,7 +394,9 @@ fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool { } /// A new image stream from 8-bit samples: the original dictionary with the new size and filter. -/// A decode array is dropped: only images without one, or with the default one, get here. +/// A decode array is dropped: only images without one, or with the default one, get here. So are +/// the filter parameters: the samples are decoded, and a JPEG written here is plain YCbCr with no +/// Adobe marker, which is what a missing /ColorTransform means. fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Compression, was_dct: bool) -> Option { let mut d = old.clone(); for k in [&b"Filter"[..], b"DecodeParms", b"Decode", b"Length", b"DL"] { @@ -335,7 +457,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us } else { (w, h) }; - let px = pixels(s, n)?; + let px = pixels(doc, s, n)?; let px = if (nw, nh) != (w, h) { resample(&px, n, w, h, nw, nh)? } else { px }; let new = image_stream(d, &px, n, nw, nh, settings.compression, was_dct)?; // A soft mask must keep the image's dimensions: resample it with the image (lossless). @@ -348,7 +470,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us return None; } let (mw, mh) = (m.dict.int(b"Width")? as u32, m.dict.int(b"Height")? as u32); - let mpx = pixels(m, 1)?; + let mpx = pixels(doc, 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)?)); } diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index c8b9b67690..0d9737d687 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -9,7 +9,8 @@ //! threshold are resampled (bicubic) to a target resolution and recompressed as JPEG or Flate. //! A new image replaces the old one only if it is smaller. Images PdfCraft can't decode //! faithfully (CMYK and other colour spaces, masks, decode arrays other than the default, -//! JPEG 2000, JBIG2, CCITT, other than 8 bits) are left alone. +//! JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker and `/ColorTransform` +//! disagree) are left alone. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), //! print settings. //! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted diff --git a/crates/optimize/tests/jpeg_color_transform.rs b/crates/optimize/tests/jpeg_color_transform.rs new file mode 100644 index 0000000000..77a3293523 --- /dev/null +++ b/crates/optimize/tests/jpeg_color_transform.rs @@ -0,0 +1,363 @@ +//! Optimize keeps a JPEG's colours whatever its colour transform says (#1201). +//! +//! A JPEG with three or four components can store its samples as they are or converted to YCbCr +//! (YCCK). An Adobe APP14 marker in the JPEG says which; without one, the filter's +//! `/ColorTransform` does (ISO 32000-2 §7.4.8, default 1 for three components). Each image here +//! is drawn by PdfCraft's own renderer before and after Optimize, and the two must show the same +//! colours, up to what a JPEG re-encode loses. + +use std::sync::Arc; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; +use pdfcraft_optimize::{Settings, optimize}; + +/// A photo-like RGB picture: colourful gradients with a little texture, so the colour transform +/// matters everywhere and a medium-quality JPEG is smaller than a maximum-quality one. +fn photo(w: u32, h: u32) -> Vec { + let mut px = Vec::with_capacity((w * h * 3) as usize); + for y in 0..h { + for x in 0..w { + let t = ((x ^ y) & 7) as u8; + px.push((x * 255 / w) as u8 ^ t); + px.push((y * 255 / h) as u8); + px.push((255 - (x + y) * 127 / (w + h)) as u8 ^ (t << 2)); + } + } + px +} + +fn rgb_jpeg(px: &[u8], w: u32, h: u32) -> Vec { + let mut out = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 100).encode(px, w, h, image::ExtendedColorType::Rgb8).unwrap(); + out +} + +/// The JPEG's segments before its first scan: (marker, start of the marker, end of the segment). +fn segments(jpeg: &[u8]) -> Vec<(u8, usize, usize)> { + assert_eq!(&jpeg[..2], &[0xFF, 0xD8]); + let (mut i, mut out) = (2, Vec::new()); + loop { + assert_eq!(jpeg[i], 0xFF, "a marker at {i}"); + let marker = jpeg[i + 1]; + let end = i + 2 + u16::from_be_bytes([jpeg[i + 2], jpeg[i + 3]]) as usize; + out.push((marker, i, end)); + if marker == 0xDA { + return out; + } + i = end; + } +} + +/// `jpeg` with an Adobe APP14 segment whose transform flag is `transform` (0: samples stored as +/// they are, 1: YCbCr, 2: YCCK). +fn with_adobe(jpeg: &[u8], transform: u8) -> Vec { + assert!(segments(jpeg).iter().all(|s| s.0 != 0xEE), "no Adobe segment yet"); + let mut v = jpeg[..2].to_vec(); + v.extend_from_slice(&[0xFF, 0xEE, 0, 14, b'A', b'd', b'o', b'b', b'e', 0, 100, 0, 0, 0, 0, transform]); + v.extend_from_slice(&jpeg[2..]); + v +} + +/// `jpeg` with its three components named R, G and B (in the frame and scan headers), which +/// JPEG decoders read as samples stored untransformed. +fn with_rgb_ids(jpeg: &[u8]) -> Vec { + let mut v = jpeg.to_vec(); + let mut ids = Vec::new(); + for (marker, i, _) in segments(jpeg) { + match marker { + 0xC0 => { + assert_eq!(v[i + 9], 3); + for k in 0..3 { + ids.push(v[i + 10 + 3 * k]); + v[i + 10 + 3 * k] = b"RGB"[k]; + } + } + 0xDA => { + for k in 0..v[i + 4] as usize { + let id = v[i + 5 + 2 * k]; + v[i + 5 + 2 * k] = b"RGB"[ids.iter().position(|&x| x == id).unwrap()]; + } + } + _ => {} + } + } + v +} + +/// A baseline JPEG `w` × `h` (multiples of 8) with four components, every 8 × 8 block one flat +/// colour `block(bx, by)` as stored, and an Adobe APP14 segment if `adobe` is given. +fn four_component_jpeg(w: u16, h: u16, adobe: Option, block: impl Fn(u16, u16) -> [u8; 4]) -> Vec { + fn put(bits: &mut Vec, value: u32, n: u32) { + bits.extend((0..n).rev().map(|i| value >> i & 1 == 1)); + } + let mut v = vec![0xFF, 0xD8]; + if let Some(t) = adobe { + v.extend_from_slice(&[0xFF, 0xEE, 0, 14, b'A', b'd', b'o', b'b', b'e', 0, 100, 0, 0, 0, 0, t]); + } + // Every quantiser 1: a flat block's DC coefficient is 8 × (sample − 128), exactly. + v.extend_from_slice(&[0xFF, 0xDB, 0, 67, 0]); + v.extend_from_slice(&[1; 64]); + v.extend_from_slice(&[0xFF, 0xC0, 0, 20, 8]); + v.extend_from_slice(&h.to_be_bytes()); + v.extend_from_slice(&w.to_be_bytes()); + v.push(4); + for id in 1..=4 { + v.extend_from_slice(&[id, 0x11, 0]); + } + // DC table: categories 0 to 11 with four-bit codes equal to the category. AC table: EOB, "0". + v.extend_from_slice(&[0xFF, 0xC4, 0, 31, 0x00, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + v.extend(0..12u8); + v.extend_from_slice(&[0xFF, 0xC4, 0, 20, 0x10, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + v.extend_from_slice(&[0xFF, 0xDA, 0, 14, 4, 1, 0, 2, 0, 3, 0, 4, 0, 0, 63, 0]); + let mut bits = Vec::new(); + let mut pred = [0i32; 4]; + for by in 0..h / 8 { + for bx in 0..w / 8 { + for (c, s) in block(bx, by).into_iter().enumerate() { + let dc = (i32::from(s) - 128) * 8; + let diff = dc - pred[c]; + pred[c] = dc; + let category = 32 - diff.unsigned_abs().leading_zeros(); + put(&mut bits, category, 4); + put(&mut bits, if diff < 0 { (diff + (1 << category) - 1) as u32 } else { diff as u32 }, category); + put(&mut bits, 0, 1); + } + } + } + bits.resize(bits.len().div_ceil(8) * 8, true); + for byte in bits.chunks(8).map(|b| b.iter().fold(0u8, |a, &x| a << 1 | u8::from(x))) { + v.push(byte); + if byte == 0xFF { + v.push(0); + } + } + v.extend_from_slice(&[0xFF, 0xD9]); + v +} + +/// An image XObject holding `jpeg`. +fn jpeg_image(doc: &mut Document, w: u32, h: u32, colour_space: &str, jpeg: Vec, parms: Parms) -> ObjRef { + 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(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name(colour_space)); + let ct = |c: i64| { + let mut p = Dict::new(); + p.set(b"ColorTransform".to_vec(), Object::Int(c)); + Object::Dict(p) + }; + match parms { + Parms::None => d.set(b"Filter".to_vec(), Object::name("DCTDecode")), + Parms::Dict(c) => { + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + d.set(b"DecodeParms".to_vec(), ct(c)); + } + Parms::Array(c) => { + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("DCTDecode")])); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![ct(c)])); + } + } + doc.add(Object::Stream(Stream::from_raw(d, jpeg))) +} + +/// How an image's `/DecodeParms` gives its `/ColorTransform`. +#[derive(Clone, Copy, Debug)] +enum Parms { + None, + /// `/Filter /DCTDecode /DecodeParms << /ColorTransform c >>`. + Dict(i64), + /// `/Filter [/DCTDecode] /DecodeParms [<< /ColorTransform c >>]`. + Array(i64), +} + +/// A document with one page, `w` × `h` pt, whose content is `content` drawing `images`. +fn one_page(doc: &mut Document, w: u32, h: u32, images: &[(String, ObjRef)], content: &str) { + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let mut x = Dict::new(); + for (name, r) in images { + x.set(name.as_bytes().to_vec(), Object::Ref(*r)); + } + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + let c = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.as_bytes().to_vec()))); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), (w as i64).into(), (h as i64).into()])); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(c)); + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(r)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); +} + +/// A document whose one page shows `jpeg` (`w` × `h` px) at `ppi`. +fn showing(w: u32, h: u32, ppi: u32, colour_space: &str, jpeg: Vec, parms: Parms) -> (Document, ObjRef) { + let mut doc = Document::new_empty(); + let img = jpeg_image(&mut doc, w, h, colour_space, jpeg, parms); + let (pw, ph) = (w * 72 / ppi, h * 72 / ppi); + one_page(&mut doc, pw, ph, &[("Im".to_string(), img)], &format!("q {pw} 0 0 {ph} 0 0 cm /Im Do Q")); + (doc, img) +} + +fn stream(doc: &Document, r: ObjRef) -> Stream { + match &*doc.get(r) { + Object::Stream(s) => s.clone(), + _ => panic!("not a stream"), + } +} + +/// The page as PdfCraft's renderer draws it, `scale` device pixels per point: RGB rows. +fn render(doc: &Document, scale: f32) -> (u32, u32, Vec) { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + (out.width, out.height, out.rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2]]).collect()) +} + +/// The mean difference per colour component between two renders of one page, leaving out a +/// two-pixel border (where the picture's edge is smoothed). +fn mean_difference(a: &(u32, u32, Vec), b: &(u32, u32, Vec)) -> f64 { + assert_eq!((a.0, a.1), (b.0, b.1), "renders of one page size"); + let (w, h) = (a.0 as usize, a.1 as usize); + let (mut sum, mut count) = (0u64, 0u64); + for y in 2..h - 2 { + for x in 2..w - 2 { + for c in 0..3 { + let i = (y * w + x) * 3 + c; + sum += u64::from(a.2[i].abs_diff(b.2[i])); + count += 1; + } + } + } + sum as f64 / count as f64 +} + +/// Re-encoding a JPEG at medium quality moves a component by about this much on average; a +/// colour transform applied or dropped wrongly moves it by tens. +const TOLERANCE: f64 = 4.0; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Outcome { + /// Re-encoded (smaller), as a plain YCbCr JPEG with no `/DecodeParms`. + Reencoded, + /// Left exactly as it was. + Untouched, +} + +#[test] +fn optimize_keeps_the_colours_of_an_rgb_jpeg_whatever_its_colour_transform() { + use Outcome::*; + let (w, h) = (128, 128); + let plain = rgb_jpeg(&photo(w, h), w, h); + // What the cases rely on: one JPEG read with and without its colour transform shows very + // different colours, so a transform lost or added by Optimize can't hide in the tolerance. + let transformed = render(&showing(w, h, 72, "DeviceRGB", plain.clone(), Parms::None).0, 1.0); + let as_stored = render(&showing(w, h, 72, "DeviceRGB", plain.clone(), Parms::Dict(0)).0, 1.0); + let apart = mean_difference(&transformed, &as_stored); + assert!(apart > 10.0 * TOLERANCE, "the fixture tells the two readings apart: {apart:.1}"); + + let mut failures = Vec::new(); + for (what, adobe, rgb_ids, parms, expect) in [ + ("no Adobe marker, no /ColorTransform", None, false, Parms::None, Reencoded), + ("no Adobe marker, /ColorTransform 1", None, false, Parms::Dict(1), Reencoded), + ("no Adobe marker, /ColorTransform 0", None, false, Parms::Dict(0), Reencoded), + ("no Adobe marker, /ColorTransform 0 in a filter array", None, false, Parms::Array(0), Reencoded), + ("Adobe YCbCr marker", Some(1), false, Parms::None, Reencoded), + ("Adobe YCbCr marker, /ColorTransform 1", Some(1), false, Parms::Dict(1), Reencoded), + ("Adobe untransformed marker", Some(0), false, Parms::None, Reencoded), + ("Adobe untransformed marker, /ColorTransform 0", Some(0), false, Parms::Dict(0), Reencoded), + // The marker overrides /ColorTransform in the standard, but readers differ (PdfCraft's + // renderer lets /ColorTransform 0 win): the image is left as it is. + ("Adobe YCbCr marker, /ColorTransform 0", Some(1), false, Parms::Dict(0), Untouched), + ("Adobe untransformed marker, /ColorTransform 1", Some(0), false, Parms::Dict(1), Untouched), + // Components named R, G and B: JPEG decoders read them untransformed, the standard's + // default transforms them. Only an explicit /ColorTransform 0 settles it. + ("components R, G, B, no /ColorTransform", None, true, Parms::None, Untouched), + ("components R, G, B, /ColorTransform 0", None, true, Parms::Dict(0), Reencoded), + ] { + let mut jpeg = plain.clone(); + if rgb_ids { + jpeg = with_rgb_ids(&jpeg); + } + if let Some(t) = adobe { + jpeg = with_adobe(&jpeg, t); + } + let (mut doc, img) = showing(w, h, 72, "DeviceRGB", jpeg, parms); + let original = stream(&doc, img); + let before = render(&doc, 1.0); + // Reduce File Size: an image at 72 ppi isn't resampled, only recompressed as JPEG 60. + let report = optimize(&mut doc, &Settings::default()).unwrap(); + let after = render(&doc, 1.0); + let new = stream(&doc, img); + let outcome = if new == original { + Untouched + } else { + assert_eq!(new.dict.name(b"Filter"), Some(&b"DCTDecode"[..]), "{what}"); + assert!(!new.dict.contains(b"DecodeParms"), "{what}: a plain JPEG has no colour transform to state"); + assert_eq!(report.images_recompressed, 1, "{what}"); + Reencoded + }; + let difference = mean_difference(&before, &after); + if outcome != expect || difference > TOLERANCE { + failures.push(format!("{what}: {outcome:?} (expected {expect:?}), colours moved by {difference:.1}")); + } + } + assert!(failures.is_empty(), "\n{}", failures.join("\n")); +} + +#[test] +fn optimize_leaves_cmyk_jpegs_as_they_are_with_or_without_an_adobe_marker() { + // 256 px drawn at 288 ppi: Reduce File Size would resample a colour space it handles. + let (w, h) = (256u16, 256u16); + let block = |bx: u16, by: u16| [(bx * 8) as u8, (by * 8) as u8, (255 - (bx + by) * 4) as u8, ((bx ^ by) * 8) as u8]; + for (adobe, parms) in [ + (None, Parms::None), + (None, Parms::Dict(0)), + (None, Parms::Dict(1)), + (Some(0), Parms::None), + (Some(0), Parms::Dict(1)), + (Some(2), Parms::None), + (Some(2), Parms::Dict(0)), + ] { + let what = format!("Adobe marker {adobe:?}, {parms:?}"); + let jpeg = four_component_jpeg(w, h, adobe, block); + let (mut doc, img) = showing(u32::from(w), u32::from(h), 288, "DeviceCMYK", jpeg, parms); + let original = doc.get(img); + let before = render(&doc, 4.0); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(doc.get(img), original, "{what}: left as it is"); + assert!(before == render(&doc, 4.0), "{what}: the page looks the same"); + } +} + +#[test] +fn merging_jpeg_strips_keeps_their_colour_transform() { + // A page picture printed as 16 JPEG strips stored untransformed (/ColorTransform 0, no + // Adobe marker): the merged image must show the strips' colours, not their samples converted + // from YCbCr. + let (w, h, rows) = (128u32, 128u32, 8u32); + let px = photo(w, h); + let mut doc = Document::new_empty(); + let mut images = Vec::new(); + let mut content = String::new(); + for k in 0..h / rows { + let band = &px[(k * rows * w * 3) as usize..((k + 1) * rows * w * 3) as usize]; + let img = jpeg_image(&mut doc, w, rows, "DeviceRGB", rgb_jpeg(band, w, rows), Parms::Dict(0)); + images.push((format!("S{k}"), img)); + content += &format!("q {w} 0 0 {rows} 0 {} cm /S{k} Do Q\n", h - (k + 1) * rows); + } + one_page(&mut doc, w, h, &images, &content); + let before = render(&doc, 1.0); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.merged_images, 1, "{report:?}"); + let difference = mean_difference(&before, &render(&doc, 1.0)); + assert!(difference <= TOLERANCE, "colours moved by {difference:.1}"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94f..29dd75701d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7284,8 +7284,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens"] -notes = "JPEG (quality steps) and ZIP; no JPEG 2000" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/optimize/tests/jpeg_color_transform.rs::optimize_keeps_the_colours_of_an_rgb_jpeg_whatever_its_colour_transform"] +notes = "JPEG (quality steps) and ZIP; a JPEG is decoded with its colour transform (Adobe marker, else /ColorTransform), and one whose marker and /ColorTransform disagree is left as it is; no JPEG 2000" [[feature]] id = "optimize.mono-compression" From 9283d2fb22ecee9bfe1b5f8a442f601b071b10b8 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:56:29 +0300 Subject: [PATCH 012/517] fix(edit): batch styled fallback glyphs (#2592) --- ROADMAP.md | 3 + crates/automation/tests/automation.rs | 37 ++++++++ crates/edit/src/tests.rs | 129 ++++++++++++++++++++++++++ crates/edit/src/text.rs | 17 +++- crates/edit/src/text_regions.rs | 84 +++++++++++++---- docs/editing-existing-text.md | 9 ++ docs/roadmap.md | 4 +- parity/acrobat-features.toml | 4 +- 8 files changed, 264 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec04..577370004e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M7 #2592: styled paragraph fallback batches by actual face; estimates unchanged) + **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away > **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) @@ -157,6 +159,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 (M7, #2592):** Styled paragraph edits share Type 3 fallback fonts by actual face, up to 240 glyphs each, instead of writing one font per new character. Consecutive equal-font/style glyphs form one run; Latin source fonts, Japanese serif/sans and weight choices, alignment, save/reopen and undo/redo have regressions. A 300-glyph replacement spans two fonts; unsupported glyphs are refused before fallback objects are added. The styled paragraph has a 15,360-distinct-fallback-glyph budget. No wider glyph coverage, mixed contextual shaping, single-style change or stale-font cleanup is claimed. 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/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f38..6d778bb99c 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2474,6 +2474,43 @@ fn editing_existing_text_through_tools() { assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}"); } +#[test] +fn styled_cyrillic_fallback_is_batched_through_tools_and_reopen() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("skipping: built without craft-fonts"); + return; + } + let dir = workdir("styled-fallback"); + std::fs::write( + dir.join("mixed.pdf"), + helvetica_page( + "BT /F1 12 Tf 14.4 TL 72 700 Td (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET", + ), + ) + .unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "mixed.pdf"}))["doc"].as_u64().unwrap(); + let replacement = "The quick brown fox jumps over the lazy dog шалом\nand keeps running through the long field."; + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":replacement,"width":520})); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"})); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("saved.pdf")).unwrap())).unwrap(); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + let resources = saved.dict(page.dict.get(b"Resources").unwrap()).unwrap(); + let fonts = saved.dict(resources.get(b"Font").unwrap()).unwrap(); + let fallback_count = fonts.iter().filter(|(name, _)| name.starts_with(b"PCJp")).count(); + assert_eq!(fallback_count, 1, "one shared fallback font for the new word"); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert!(!page_text(&mut a, doc)[0].contains("шалом")); + ok(&mut a, "edit_redo", json!({"doc":doc})); + assert!(page_text(&mut a, doc)[0].contains("шалом")); + let reopened = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + let lines = ok(&mut a, "text_lines", json!({"doc":reopened,"page":1})); + let runs = lines["lines"].as_array().unwrap(); + assert!(runs.iter().any(|l| l["text"].as_str().is_some_and(|t| t.starts_with("The quick")) && l["font"] == "Helvetica"), "{lines}"); + assert_eq!(runs.iter().filter(|l| l["text"] == "шалом").count(), 1, "{lines}"); + assert!(page_text(&mut a, reopened)[0].contains("шалом")); +} + /// One page whose content is one stream in three pieces (#155): a `TJ` array ends the middle /// piece and its operator starts the last one. fn split_streams_pdf() -> Vec { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df8..a8c0b23493 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1153,6 +1153,135 @@ fn japanese_replacement_over_240_unique_glyphs_is_split_across_fallback_fonts() assert_eq!(glyphs, 300); } +#[test] +fn styled_paragraph_batches_cyrillic_without_changing_source_fonts() { + if without_craft_fonts("styled_paragraph_batches_cyrillic_without_changing_source_fonts") { + return; + } + let mut doc = text_page( + "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET", + ); + let replacement = "The quick brown fox jumps over the lazy dog шалом\nand keeps running through the long field."; + text::rewrite_block(&mut doc, 0, 0, Some(replacement), &text::BlockStyle { width: Some(520.0), ..Default::default() }).unwrap(); + let doc = reopen(&doc); + assert_eq!(fallback_fonts(&doc).len(), 1, "the five letters must share one font"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.iter().any(|l| l.base_font == "Helvetica" && l.text.starts_with("The quick brown fox")), "{lines:?}"); + let fallback: Vec<_> = lines.iter().filter(|l| l.font.starts_with("PCJp")).collect(); + assert_eq!(fallback.len(), 1, "one word must be one text run: {fallback:?}"); + assert_eq!(fallback[0].text, "шалом"); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::().replace(' ', ""), replacement.replace([' ', '\n'], "")); +} + +#[test] +fn styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically() { + let replacement = format!("{}\nLatin", covered_japanese_chars(300)); + let mut doc = text_page("BT /F1 12 Tf 14.4 TL 72 700 Td (Original) Tj T* (Latin) Tj ET"); + let original = write_incremental(&doc, &SaveOptions::default()).unwrap(); + if without_craft_fonts("styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically") { + assert!(text::replace_block(&mut doc, 0, 0, &replacement).is_err()); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original); + return; + } + text::rewrite_block(&mut doc, 0, 0, Some(&replacement), &text::BlockStyle { width: Some(14000.0), ..Default::default() }).unwrap(); + let reopened = reopen(&doc); + let fonts = fallback_fonts(&reopened); + assert_eq!(fonts.len(), 2, "300 glyphs need two fonts, not an error at glyph 241"); + let counts: Vec<_> = fonts.iter().map(|(_, f)| reopened.dict(f.get(b"CharProcs").unwrap()).unwrap().len()).collect(); + assert_eq!(counts.iter().sum::(), 300); + assert!(counts.iter().all(|n| *n <= 240)); + assert_eq!(text::text_lines(&reopened, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), replacement.replace('\n', "")); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let snapshot = doc.clone(); + let err = text::replace_block(&mut doc, 0, 0, &format!("{replacement}\u{10ffff}")).unwrap_err(); + assert!(err.to_string().contains("U+10FFFF"), "{err}"); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); + assert_eq!(write_incremental(&snapshot, &SaveOptions::default()).unwrap(), before); +} + +#[test] +fn styled_fallback_batches_keep_face_choices_and_range_styles() { + if without_craft_fonts("styled_fallback_batches_keep_face_choices_and_range_styles") { + return; + } + if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") { + eprintln!("skipping: no bold Gothic face"); + return; + } + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original) Tj ET"); + let range = |range, family, bold| text::RangeStyle { + range, + family: Some((family, bold, false)), + resource: None, + size: None, + color: None, + underline: None, + char_spacing: None, + scale: None, + }; + let style = text::BlockStyle { + width: Some(520.0), + ranges: vec![ + range(3..6, added::Family::Times, false), + range(6..9, added::Family::Helvetica, true), + range(9..11, added::Family::Times, false), + ], + ..Default::default() + }; + let original = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let err = text::rewrite_block(&mut doc, 0, 0, Some("見見見ш見ш\nLatin\u{10ffff}"), &style).unwrap_err(); + assert!(err.to_string().contains("U+10FFFF"), "{err}"); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original, "all faces must validate before writing fallback objects"); + text::rewrite_block(&mut doc, 0, 0, Some("見見見ш見ш\nLatin"), &style).unwrap(); + let doc = reopen(&doc); + let fonts = fallback_fonts(&doc); + let mut summary: Vec<_> = fonts + .iter() + .map(|(_, f)| { + let face = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, f).base_font; + let count = doc.dict(f.get(b"CharProcs").unwrap()).unwrap().len(); + (face, count) + }) + .collect(); + summary.sort(); + assert_eq!( + summary, + [ + ("BIZUDMincho-Regular".into(), 1), + ("BIZUDPGothic-Bold".into(), 1), + ("BIZUDPGothic-Regular".into(), 2), + ("ShipporiMincho-Regular".into(), 1) + ] + ); + assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), "見見見ш見шLatin"); +} + +#[test] +fn styled_fallback_shards_keep_text_advances_and_alignment() { + if without_craft_fonts("styled_fallback_shards_keep_text_advances_and_alignment") { + return; + } + let source = "BT /F1 12 Tf 0.5 Tc 5 Tw 80 Tz 14.4 TL 72 700 Td (Original row) Tj T* (Second row) Tj ET"; + let style = + text::BlockStyle { width: Some(520.0), align: Some(added::Align::Center), char_spacing: Some(0.7), scale: Some(80.0), ..Default::default() }; + let mut whole = text_page(source); + let mut split = text_page(source); + let replacement = "Latin шалом ш\nSecond row"; + text::rewrite_block_with_limit(&mut whole, 0, 0, Some(replacement), &style, 240).unwrap(); + text::rewrite_block_with_limit(&mut split, 0, 0, Some(replacement), &style, 2).unwrap(); + let (whole, split) = (reopen(&whole), reopen(&split)); + assert_eq!(fallback_fonts(&whole).len(), 1); + assert_eq!(fallback_fonts(&split).len(), 3); + let (one, many) = (text::text_lines(&whole, 0).unwrap(), text::text_lines(&split, 0).unwrap()); + assert_eq!(one.len(), many.len(), "{one:?} vs {many:?}"); + for (a, b) in one.iter().zip(&many) { + assert_eq!(a.text, b.text); + for (a, b) in a.rect.iter().zip(b.rect) { + assert!((a - b).abs() < 0.002, "sharding must preserve alignment and advances: {a} vs {b}"); + } + } +} + /// A replacement whose last glyph no face draws (in a later font) changes nothing: neither the /// document nor a snapshot taken before the edit, which is what undo restores. #[test] diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 9c83cf9a39..8f9adfe407 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1507,6 +1507,19 @@ fn type3_font( .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; glyphs.push(Type3Glyph { code, ch, path, width }); } + Ok(write_type3_glyphs(doc, fonts_res, face, hebrew, glyphs, per_font)) +} + +/// Write already validated outlines; callers can prepare every face before adding any objects. +fn write_type3_glyphs( + doc: &mut Document, + fonts_res: &mut Dict, + face: &CraftFont, + hebrew: bool, + glyphs: Vec, + per_font: usize, +) -> Type3Fallback { + let per_font = per_font.clamp(1, MAX_TYPE3_GLYPHS); let mut codes = HashMap::with_capacity(glyphs.len()); let mut widths = Vec::with_capacity(glyphs.len()); let mut names = Vec::new(); @@ -1522,7 +1535,7 @@ fn type3_font( } names.push(add_type3_font(doc, fonts_res, face, hebrew, group)); } - Ok(Type3Fallback { family: face.family, names, widths, codes }) + Type3Fallback { family: face.family, names, widths, codes } } /// Adds one generated font whose codes 1 to `glyphs.len()` show `glyphs`, and returns its resource @@ -2045,7 +2058,7 @@ fn rewrite_block_from( && style.family.is_none() && style.bold.is_none()) { - return rewrite_styled_block(doc, page, &b, &lines, text, style); + return rewrite_styled_block(doc, page, &b, &lines, text, style, max_glyphs); } let streams = content_streams(doc, &p.dict); let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 2d7baf519f..5078b3d67b 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -2,6 +2,16 @@ use super::*; +// The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces. +const MAX_STYLED_FALLBACK_GLYPHS: usize = MAX_TYPE3_GLYPHS * 64; + +struct FallbackGroup { + face: &'static CraftFont, + seen: std::collections::HashSet, + glyphs: Vec, + positions: Vec, +} + fn same_plane(a: &TextLine, b: &TextLine) -> bool { let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm)); let bt = Matrix(b.origin.tm).then(&Matrix(b.origin.ctm)); @@ -248,6 +258,7 @@ pub(super) fn rewrite_styled_block( lines: &[TextLine], text: Option<&str>, style: &BlockStyle, + max_glyphs: usize, ) -> Result { let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; let draft = match text { @@ -390,7 +401,10 @@ pub(super) fn rewrite_styled_block( // Each tuple is a character, its source style and an encodable resource name. let mut chars = Vec::new(); let mut substituted = None; - let mut fallback_names: std::collections::BTreeMap<(String, char), String> = std::collections::BTreeMap::new(); + let mut fallback_groups: Vec = Vec::new(); + let mut fallback_names: std::collections::BTreeMap<(String, bool, char), usize> = std::collections::BTreeMap::new(); + let mut fallback_glyphs = 0_usize; + let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); let mut full_faces: HashMap = HashMap::new(); let mut programs: HashMap>>> = HashMap::new(); for (range, index) in styled_spans { @@ -453,24 +467,40 @@ pub(super) fn rewrite_styled_block( } else { let family = source_family(&source.base_font); if needs_type3(&s) { - let key = (source.base_font.rsplit('+').next().unwrap_or(&source.base_font).to_owned(), ch); - if let Some(name) = fallback_names.get(&key) { - chars.push((ch, index, name.clone())); - continue; + let key = (source.base_font.clone(), source.bold, ch); + let group_index = if let Some(index) = fallback_names.get(&key) { + *index + } else { + // Keep the old per-character face choice: a serif Japanese character + // and a Cyrillic character may need different faces even in one style. + // Hebrew uses the separate RTL path before reaching this writer. + let (faces, _) = type3_faces(&s, family, source.bold)?; + let face = + fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(&s))?; + let index = fallback_groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| { + let index = fallback_groups.len(); + fallback_groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() }); + index + }); + fallback_names.insert(key, index); + index + }; + let group = fallback_groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; + if group.seen.insert(ch) { + if fallback_glyphs >= MAX_STYLED_FALLBACK_GLYPHS { + return Err(EditError::Invalid(format!( + "the replacement needs more than {MAX_STYLED_FALLBACK_GLYPHS} fallback glyphs; edit a smaller paragraph or load a complete font" + ))); + } + let (path, width) = type3_path(group.face, ch)?; + let code = u8::try_from((group.glyphs.len() % per_font).saturating_add(1)) + .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; + group.glyphs.push(Type3Glyph { code, ch, path, width }); + fallback_glyphs = fallback_glyphs.saturating_add(1); } - if fallback_names.len() >= MAX_TYPE3_GLYPHS { - return Err(EditError::Invalid("the replacement has too many fallback glyphs".into())); - } - let fallback = type3_font(doc, &mut fonts, &s, family, source.bold, MAX_TYPE3_GLYPHS)?; - let name = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; - let dict = fonts - .get(name.as_bytes()) - .and_then(|f| doc.resolve(f).as_dict().cloned()) - .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &dict)); - substituted = Some(fallback.family.to_string()); - fallback_names.insert(key, name.clone()); - name + group.positions.push(chars.len()); + // Filled from the prepared font/code map before measuring or drawing. + String::new() } else { let base = family.base_font(source.bold, source.italic); let name = format!("PCEd{}", base.replace('-', "")); @@ -488,6 +518,24 @@ pub(super) fn rewrite_styled_block( chars.push((ch, index, name)); } } + // All fallback outlines have now succeeded. Batch by actual face, preserving source + // style indices; the existing writer then coalesces adjacent equal-font/style characters. + for group in fallback_groups { + let fallback = write_type3_glyphs(doc, &mut fonts, group.face, false, group.glyphs, per_font); + for name in &fallback.names { + let font = fonts + .get(name.as_bytes()) + .and_then(|f| doc.dict(f)) + .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; + metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); + } + for position in group.positions { + let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?; + let (font, _) = fallback.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?; + item.2 = fallback.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?; + } + substituted = Some(fallback.family.to_owned()); + } let size_of = |source: &TextLine, index: usize| { overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6) }; diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index e8bb03b9a4..ee6e4b0e42 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -37,6 +37,15 @@ this works only when that build input actually contains the full face. This chan add a font binary or change the release font pin. Fonts shared by releases belong in `storytold/craft-fonts` under the repository's asset rules. +When mixed-style text needs outline fallback, characters using the same actual fallback face +share Type 3 fonts, with at most 240 distinct glyphs per font. Adjacent characters with the same +font and source style are written as one run. Latin characters that the source font can encode +stay in it; Japanese serif/sans and weight choices remain separate. More than 240 missing glyphs +use additional fonts; a styled paragraph needing more than 15,360 distinct fallback glyphs is +refused with an error. Unsupported glyphs are refused before fallback objects are written. +This does not expand glyph coverage, shape mixed contextual runs or remove old unused fonts +when editing again. The separate single-style fallback path is unchanged. + Rewrapped Chinese and Japanese text, which has no spaces between words, may break between any two characters. A row never begins with closing punctuation, a closing bracket or quote, a small kana or the prolonged sound mark, and never ends with an opening bracket or quote or a currency diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f4..356634bb40 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 (M7 #2592: styled paragraph fallback batches by actual face; 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) @@ -118,7 +120,7 @@ total matches the parity estimate. | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | | 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: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | -| 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). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| 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). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks #2592: styled fallback fonts batch up to 240 glyphs per actual face, retaining source styles; glyph coverage and complex mixed runs remain limited. | | 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). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | 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), watermark print/screen visibility and repeat | | 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. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6dbc34f35f..fece02ef5e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1866,8 +1866,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"] -notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/automation/tests/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen"] +notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md" [[feature]] id = "edit.text-reflow" From 32ca91a31f1ee222d48c377e9ae24db6482c724e Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Fri, 16 Oct 2026 19:00:02 +0200 Subject: [PATCH 013/517] M5: sticky notes stay upright where they are placed on rotated pages (#2583) Notes and file attachments are saved with the NoRotate flag (/F bit 5), but nothing honoured it: on a page with /Rotate the icon was fitted to /Rect and turned with the page, and sat away from where it was placed. The vendored renderer now honours NoRotate (hayro-interpret patch 37): the appearance fitted to /Rect is turned against the page's rotation about the rectangle's upper-left corner, which stays put (ISO 32000-2 12.5.3). Form fields and links stay in /Rect, where their click targets come from. A counterrotated appearance, as stamps use, would be turned twice by viewers that honour the flag. Annotation::shown_rect gives the box the note is drawn in. The UI uses it for hit-testing, outlines, moving, the hover pop-up, the Comments panel flash and the edit composer; a resize maps it back to /Rect. comment_add anchors an icon's displayed top-left at `at`, and comment_list, doc_info and comment_edit use the drawn box. This also covers the sticky-note part of #1869. --- ROADMAP.md | 1 + crates/annot/README.md | 5 + crates/annot/src/lib.rs | 14 +++ crates/automation/src/comments.rs | 23 ++-- crates/automation/src/lib.rs | 3 +- crates/engine/src/lib.rs | 1 + crates/engine/src/tests.rs | 41 +++++++ crates/render/src/inspect.rs | 115 ++++++++++++++++++++ crates/render/src/raster.rs | 85 +++++++++++++++ crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/comments.rs | 32 ++++-- crates/ui-egui/src/comments_panel.rs | 7 +- crates/ui-egui/tests/comments.rs | 32 ++++++ parity/acrobat-features.toml | 4 +- vendor/README.md | 2 +- vendor/hayro-interpret/src/interpret/mod.rs | 34 +++++- 16 files changed, 373 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec04..6bb2c08334 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,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-16 (M5, #2583, #1869):** A sticky note on a page with `/Rotate` is drawn upright, with its top-left where it was placed. Notes are saved with the NoRotate flag, and the renderer now honours it: the appearance is turned against the page's rotation about the upper-left corner of `/Rect`, which stays put (ISO 32000-2 §12.5.3; vendored hayro-interpret patch 37). Attachment icons follow the same rule. Selection, hover, the edit composer, the Comments panel's flash and the `comment_list` and `doc_info` rectangles use the drawn box, and `comment_add` anchors an icon's displayed top-left at `at`. 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/annot/README.md b/crates/annot/README.md index 8ba3ca8c4e..55df6d1a75 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -51,6 +51,11 @@ deterministic; the engine supplies them. signature) and PdfCraft's rubber stamps counterrotate their appearance on rotated pages, so the picture or label reads upright as the page is displayed. Restyling a picture stamp keeps that appearance matrix. The rotation is the page's `/Rotate` at placement. +- Sticky notes and file attachments are not counterrotated: they carry the NoRotate flag and + their icon is drawn in an unturned 20 × 20 box. A viewer keeps them upright on a turned page, + about the upper-left corner of `/Rect` (ISO 32000-2 §12.5.3). PdfCraft's renderer does so + (vendored hayro-interpret patch 37), and `Summary::no_rotate` tells the UI where the icon is + drawn. ## Not yet diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index f4973aa021..590848cb2d 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -751,6 +751,17 @@ fn annotation_flags(doc: &Document, d: &Dict) -> i64 { d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) } +/// Whether the renderer keeps the annotation upright on a turned page: the NoRotate flag, with +/// `/F` read as the renderer reads it (a real truncated, a value that isn't a `u32` no flags). +fn drawn_upright(doc: &Document, d: &Dict) -> bool { + let f = match d.get(b"F").map(|f| doc.resolve(f)).as_deref() { + Some(Object::Int(i)) => *i, + Some(Object::Real(r)) => *r as i64, + _ => 0, + }; + u32::try_from(f).is_ok_and(|f| i64::from(f) & FLAG_NO_ROTATE != 0) +} + /// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden). pub fn has_visible_fill_sign(doc: &Document) -> bool { let Ok(pages) = page_refs(doc) else { return false }; @@ -2082,6 +2093,8 @@ pub struct Summary { pub intent: Option, /// A Fill & Sign mark ([`is_fill_sign`]). pub fill_sign: bool, + /// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`. + pub no_rotate: bool, } fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { @@ -2155,6 +2168,7 @@ pub fn summaries(doc: &Document) -> Vec { locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())), fill_sign: is_fill_sign(doc, d), + no_rotate: drawn_upright(doc, d), }); } } diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 302688f729..109e4acf33 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -3,9 +3,7 @@ //! Geometry follows the automation convention: points from the top-left of the displayed page, //! y down. It is converted to PDF user space (crop box, `/Rotate`) here. -use pdfcraft_engine::{ - Edit, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort, -}; +use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort}; use pdfcraft_render::{Annotation, PageInfo}; use serde_json::{Value, json}; @@ -79,13 +77,11 @@ fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] { } /// The engine's `at` for a note or attachment icon whose top-left corner *as displayed* is the -/// view point (x, y): the top-left (`[x0, y1]`) of the icon's square in user space. Converting -/// the point alone is only right on unrotated pages — under `/Rotate` that corner of the -/// displayed square is another corner of the user-space one, and the icon lands one icon-width -/// away from where it was asked for. +/// view point (x, y): the upper-left corner (`[x0, y1]`) of its `/Rect`. Notes and attachments +/// are NoRotate, so on a turned page the icon is drawn upright with its top-left at that corner +/// (ISO 32000-2 §12.5.3), and `comment_list` reports that square (`Annotation::shown_rect`). fn icon_anchor(p: &PageInfo, x: f64, y: f64) -> [f64; 2] { - let r = rect_to_user(p, [x, y, x + NOTE_SIZE, y + NOTE_SIZE]); - [r[0], r[3]] + to_user(p, x, y) } /// A user-space rectangle as the displayed-page rectangle every tool reports and accepts: @@ -182,7 +178,7 @@ impl Automation { "author": c.author, "contents": c.contents, "modified": c.modified, - "rect": rect_to_view(p, c.rect), + "rect": rect_to_view(p, c.shown_rect(p.rotation)), "color": c.color.map(hex), }) }; @@ -626,7 +622,12 @@ impl Automation { edits.push(Edit::FontSizeAnnotation { page, index, size }); } if let Some(r) = a.nums::<4>("rect")? { - edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) }); + // The rectangle as comment_list reports it: where the comment is drawn. + let doc = self.doc(a)?; + let rect = rect_to_user(&info, r); + let rect = + doc.info.annotations.iter().find(|c| c.page == page && c.index == index).map_or(rect, |c| c.rect_for_shown(rect, info.rotation)); + edits.push(Edit::ResizeAnnotation { page, index, rect }); } if let Some([dx, dy]) = a.nums::<2>("move")? { let (o, d) = (to_user(&info, 0.0, 0.0), to_user(&info, dx, dy)); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 88856305b2..e1abeaa6c8 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2417,7 +2417,8 @@ fn info(d: &Document) -> Value { // geometry-taking tools (#129). "annotations": i.annotations.iter().map(|a| json!({ "page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents, - "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, "rect": view_rect(i, a.page, a.rect), + "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, + "rect": view_rect(i, a.page, a.shown_rect(i.pages.get(a.page).map_or(0, |p| p.rotation))), })).collect::>(), "fields": i.fields.iter().map(|f| json!({ "name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3432cd868e..fd8c66caca 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2710,6 +2710,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec Vec<[u8; 4]> { + (80..120).flat_map(|y| (200..240).map(move |x| (x, y))).map(|(x, y)| stamp_pixel(rendered, x, y)).collect() + }; + let mut upright = None; + for degrees in [0, 90, 180, 270] { + let (mut session, id) = rotated_blank(degrees); + let info = session.get(id).unwrap().info.pages[0].clone(); + let [x, y] = info.view_to_user(100.0, 40.0); + let shape = Shape::Note { at: [f64::from(x), f64::from(y)], icon: NoteIcon::Comment }; + let style = Style::default_for(&shape); + session.apply(id, Edit::AddAnnotation(NewAnnotation { page: 0, style, shape, contents: "Turned".into(), author: "Test".into() })).unwrap(); + let mut check = |session: &Session, id: DocId| { + let doc = session.get(id).unwrap(); + let info = &doc.info.pages[0]; + let note = &doc.info.annotations[0]; + assert!(note.no_rotate, "rotation {degrees}: notes are saved NoRotate"); + let shown = note.shown_rect(info.rotation); + let (a, b) = (info.user_to_view(shown[0], shown[1]), info.user_to_view(shown[2], shown[3])); + let shown = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + assert!(shown.iter().zip([100.0, 40.0, 120.0, 60.0]).all(|(v, w)| (v - w).abs() < 0.01), "rotation {degrees}: shown at {shown:?}"); + let icon = crop(&render_page(session, id)); + let reference = upright.get_or_insert_with(|| icon.clone()); + assert!(reference.iter().filter(|c| c.iter().any(|v| *v < 200)).count() > 100, "the note's icon is drawn"); + let off = icon.iter().zip(reference.iter()).filter(|(a, b)| a.iter().zip(b.iter()).any(|(a, b)| a.abs_diff(*b) > 48)).count(); + assert!(off <= 8, "rotation {degrees}: {off} of the icon's {} pixels differ from the unturned page's", icon.len()); + }; + check(&session, id); + let saved = session.save_bytes(id).unwrap(); + let mut reopened = Session::new().with_clock(|| 1_700_000_000); + let again = reopened.open("drawing.pdf", None, saved, None).unwrap(); + check(&reopened, again); + } +} + /// Fill & Sign text and text boxes read across as displayed on turned pages (scanned forms are /// often stored sideways with `/Rotate`), including after save. #[test] diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 3c3988b7b5..fb2b5a9c7a 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -383,6 +383,10 @@ pub struct Annotation { /// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture /// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`). pub fill_sign: bool, + /// The NoRotate flag (`/F` bit 5), set on sticky notes and attachments: on a turned page the + /// appearance stays upright, turned about the upper-left corner of `rect` + /// ([`Self::shown_rect`]). + pub no_rotate: bool, } impl Annotation { @@ -390,6 +394,58 @@ impl Annotation { pub fn is_mark(&self) -> bool { matches!(self.state.as_deref(), Some("Marked" | "Unmarked")) } + + /// Where the comment is drawn on a page turned by `rotation` (`PageInfo::rotation`), in user + /// space: `rect`, or for a NoRotate comment `rect` turned upright about its upper-left corner + /// (see [`Self::no_rotate`]). Hit-testing, selection and anything placed beside the comment + /// use this, so they match the drawing. + pub fn shown_rect(&self, rotation: u16) -> [f32; 4] { + self.shown(self.rect.map(f64::from), rotation).map(|v| v as f32) + } + + /// [`Self::shown_rect`] for this comment with its `/Rect` at `rect` (one being moved or + /// resized). + pub fn shown(&self, rect: [f64; 4], rotation: u16) -> [f64; 4] { + if self.no_rotate { upright_rect(rect, rotation) } else { rect } + } + + /// The `/Rect` that shows this comment in the user-space box `shown`: the inverse of + /// [`Self::shown`], for a box resized as drawn. + pub fn rect_for_shown(&self, shown: [f64; 4], rotation: u16) -> [f64; 4] { + if self.no_rotate { rect_from_upright(shown, rotation) } else { shown } + } +} + +/// The user-space box a NoRotate annotation with `/Rect` `rect` is drawn in on a page turned +/// clockwise by `rotation` (ISO 32000-2 §12.5.3): `rect` turned as far counterclockwise about its +/// upper-left corner `(x0, y1)`, which stays where it is on the page. As displayed, the box's +/// top-left is that corner, the rectangle's width runs across and its height down, as on an +/// unturned page. `rect` itself for 0 or anything that isn't a quarter turn. The vendored +/// renderer draws the appearance there (hayro-interpret patch 37). +fn upright_rect(rect: [f64; 4], rotation: u16) -> [f64; 4] { + let [x0, y0, x1, y1] = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let (w, h) = (x1 - x0, y1 - y0); + match rotation { + 90 => [x0, y1, x0 + h, y1 + w], + 180 => [x0 - w, y1, x0, y1 + h], + 270 => [x0 - h, y1 - w, x0, y1], + _ => [x0, y0, x1, y1], + } +} + +/// The `/Rect` whose [`upright_rect`] is `shown`: `shown` turned back about the corner that +/// stays put. +fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] { + let [s0, s1, s2, s3] = [shown[0].min(shown[2]), shown[1].min(shown[3]), shown[0].max(shown[2]), shown[1].max(shown[3])]; + let (sw, sh) = (s2 - s0, s3 - s1); + // The upper-left corner of `/Rect` and the rectangle's width and height. + let ((x0, y1), w, h) = match rotation { + 90 => ((s0, s1), sh, sw), + 180 => ((s2, s1), sw, sh), + 270 => ((s2, s3), sh, sw), + _ => return [s0, s1, s2, s3], + }; + [x0, y1 - h, x0 + w, y1] } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1141,6 +1197,7 @@ impl<'a> Inspector<'a> { locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, intent: self.name(d, b"IT"), fill_sign, + no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0), }); } } @@ -1554,6 +1611,17 @@ fn close_outline(open: &mut Vec<(usize, OutlineItem)>, roots: &mut Vec u32 { + let f = match o { + Object::Integer(i) => *i, + Object::Real(r) => *r as i64, + _ => 0, + }; + u32::try_from(f).unwrap_or(0) +} + fn rect4(o: &Object) -> [f32; 4] { match o { Object::Array(a) if a.len() == 4 => { @@ -1900,6 +1968,53 @@ mod tests { assert_eq!(p.view_rect_to_quad([0.0, 0.0, 10.0, 5.0]), [10.0, 320.0, 20.0, 320.0, 10.0, 315.0, 20.0, 315.0]); } + /// #2583: a NoRotate comment is drawn upright with its top-left as displayed at the upper-left + /// corner of `/Rect`, its width across and its height down; a box resized as drawn maps back to + /// the `/Rect` that shows it. Other comments, and pages that aren't quarter-turned, keep `/Rect`. + #[test] + fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() { + let mut note = super::Annotation { + page: 0, + subtype: "Text".into(), + author: None, + contents: None, + modified: None, + name: None, + in_reply_to: None, + rect: [40.0, 50.0, 70.0, 70.0], + color: None, + index: 0, + state: None, + quads: Vec::new(), + locked: false, + intent: None, + fill_sign: false, + no_rotate: true, + }; + let rect = note.rect.map(f64::from); + for rotation in [0u16, 90, 180, 270, 45] { + let (w, h) = if rotation % 180 == 90 { (140.0, 200.0) } else { (200.0, 140.0) }; + let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [0.0, 0.0, 200.0, 140.0], rotation }; + let shown = note.shown_rect(rotation); + let (a, b) = (p.user_to_view(shown[0], shown[1]), p.user_to_view(shown[2], shown[3])); + let [cx, cy] = p.user_to_view(40.0, 70.0); + let want = [cx, cy, cx + 30.0, cy + 20.0]; + let got = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{rotation}: shown {got:?}, want {want:?}"); + assert_eq!(note.rect_for_shown(note.shown(rect, rotation), rotation), rect, "{rotation}"); + // Moved by (5, -7) and resized to 50 × 10 as drawn, back to a /Rect. + let resized = [shown[0] as f64 + 5.0, shown[1] as f64 - 7.0, 0.0, 0.0]; + let across = if rotation % 180 == 90 { [10.0, 50.0] } else { [50.0, 10.0] }; + let resized = [resized[0], resized[1], resized[0] + across[0], resized[1] + across[1]]; + let back = note.rect_for_shown(resized, rotation); + assert_eq!((back[2] - back[0], back[3] - back[1]), (50.0, 10.0), "{rotation}: {back:?}"); + assert_eq!(note.shown(back, rotation), resized, "{rotation}"); + } + note.no_rotate = false; + assert_eq!(note.shown_rect(90), note.rect); + assert_eq!(note.rect_for_shown([1.0, 2.0, 3.0, 4.0], 90), [1.0, 2.0, 3.0, 4.0]); + } + use super::*; #[test] diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 8d5686dd8c..04f2f46b67 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -3034,6 +3034,91 @@ trailer << /Root 1 0 R >> } } + /// #2583, #1869: a sticky note carries the NoRotate flag (`/F` bit 5), so on a page with + /// `/Rotate` its icon stays upright as displayed, turned about the upper-left corner of its + /// `/Rect`, which stays where it is on the page (ISO 32000-2 §12.5.3). It was drawn turned with + /// the page, in `/Rect` as displayed (vendored hayro-interpret patch 37). A note without the + /// flag still turns with the page. `Annotation::shown_rect` is where the UI hit-tests the + /// note, and must be where it is drawn. + #[test] + fn no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages() { + // A 30 × 20 appearance: red in its top-left quarter, blue elsewhere. + let content = "0 0 1 rg 0 0 30 20 re f 1 0 0 rg 0 10 15 10 re f"; + for rotation in [0u16, 90, 180, 270] { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 140] /Rotate {rotation} /Annots [4 0 R 5 0 R] >>"), + // NoRotate (and Print), as PdfCraft and Acrobat save notes. + "<< /Type /Annot /Subtype /Text /F 20 /Rect [40 50 70 70] /AP << /N 6 0 R >> >>".to_string(), + // The same appearance without the flag. + "<< /Type /Annot /Subtype /Text /F 4 /Rect [120 50 150 70] /AP << /N 6 0 R >> >>".to_string(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 30 20] /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + let pdf = Arc::new(pdf); + let info = crate::inspect(pdf.clone(), None).expect("opens"); + let page = &info.pages[0]; + assert_eq!(page.rotation, rotation); + let mut r = PageRenderer::new(pdf, RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + // Displayed-page points are device pixels at scale 1. + let px = |x: f32, y: f32| -> [u8; 4] { + let (x, y) = (x.clamp(0.0, p.width as f32 - 1.0) as u32, y.clamp(0.0, p.height as f32 - 1.0) as u32); + p.rgba[((y * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + let red: fn([u8; 4]) -> bool = |c| c[0] > 200 && c[1] < 60 && c[2] < 60; + let blue: fn([u8; 4]) -> bool = |c| c[2] > 200 && c[0] < 60 && c[1] < 60; + let paper: fn([u8; 4]) -> bool = |c| c.iter().all(|v| *v > 240); + + // The NoRotate note: its upper-left corner (40, 70) is the top-left of an upright + // 30 × 20 icon as displayed, red quarter top-left. + let [cx, cy] = page.user_to_view(40.0, 70.0); + for ((dx, dy), want, what) in [ + ((7.5, 5.0), red, "red top-left quarter"), + ((22.5, 5.0), blue, "blue top-right"), + ((7.5, 15.0), blue, "blue bottom-left"), + ((22.5, 15.0), blue, "blue bottom-right"), + ((-3.0, 10.0), paper, "paper left of the icon"), + ((10.0, -3.0), paper, "paper above the icon"), + ((33.0, 10.0), paper, "paper right of its 30 pt width"), + ((10.0, 23.0), paper, "paper below its 20 pt height"), + ] { + let c = px(cx + dx, cy + dy); + assert!(want(c), "rotation {rotation}: NoRotate note at corner + ({dx}, {dy}) is {c:?}, want {what}"); + } + let note = info.annotations.iter().find(|a| a.no_rotate).expect("the NoRotate note is listed"); + let shown = note.shown_rect(rotation); + let (a, b) = (page.user_to_view(shown[0], shown[1]), page.user_to_view(shown[2], shown[3])); + let box_ = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + let want = [cx, cy, cx + 30.0, cy + 20.0]; + assert!(box_.iter().zip(want).all(|(v, w)| (v - w).abs() < 0.01), "rotation {rotation}: shown {box_:?}, drawn {want:?}"); + + // The note without the flag turns with the page: its red quarter stays at the + // user-space top-left of /Rect, at the displayed top-left only when unturned. + let plain = info.annotations.iter().find(|a| !a.no_rotate).expect("the plain note is listed"); + assert_eq!(plain.shown_rect(rotation), plain.rect); + let [rx, ry] = page.user_to_view(127.5, 65.0); + let [bx, by] = page.user_to_view(142.5, 55.0); + assert!(red(px(rx, ry)) && blue(px(bx, by)), "rotation {rotation}: the plain note's appearance turns with the page"); + let (a, b) = (page.user_to_view(120.0, 50.0), page.user_to_view(150.0, 70.0)); + let corner = px(a[0].min(b[0]) + 4.0, a[1].min(b[1]) + 4.0); + assert_eq!(red(corner), rotation == 0, "rotation {rotation}: displayed top-left of the plain note is {corner:?}"); + } + } + /// #1968: the highlight appearance other editors write, a transparency group whose content is /// filled with `/BM /Multiply`, drew as an opaque block over the text. Every `/Group` form /// got a layer of its own, so the Multiply blended with that layer's transparent backdrop diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0acab64a08..3704a98eae 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3354,7 +3354,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser)); let gesturing = view.comments.gesture.is_some() || freehand; for a in info.annotations.iter().filter(|a| a.page == i && !gesturing) { - let sr = xf.user_rect(info, i, a.rect); + let sr = xf.user_rect(info, i, a.shown_rect(info.pages.get(i).map_or(0, |p| p.rotation))); let hidden = prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty); if sr.contains(p) && hover_text.is_none() @@ -5141,6 +5141,7 @@ mod tests { locked: false, intent: None, fill_sign, + no_rotate: false, } } diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 5a708c14f6..b677431fe8 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -553,8 +553,14 @@ impl PageCx<'_> { self.xf.norm_to_screen(v[0] / self.xf.pw, v[1] / self.xf.ph) } + /// The page's clockwise `/Rotate`. + fn rotation(&self) -> u16 { + self.info.pages.get(self.page).map_or(0, |p| p.rotation) + } + + /// Where a comment is drawn on screen (a sticky note stays upright on a turned page). fn screen_rect(&self, a: &Annotation) -> Rect { - self.xf.user_rect(self.info, self.page, a.rect) + self.xf.user_rect(self.info, self.page, a.shown_rect(self.rotation())) } /// Top-level comments on this page, in paint order. @@ -625,16 +631,13 @@ impl PageCx<'_> { if let Some(Edit::ResizeAnnotation { page, index, rect }) = pending && (*page, *index) == (self.page, a.index) { - return self.xf.user_rect(self.info, self.page, rect.map(|v| v as f32)); + return self.xf.user_rect(self.info, self.page, a.shown(*rect, self.rotation()).map(|v| v as f32)); } if let Some(Edit::MoveAnnotation { page, index, dx, dy }) = pending && (*page, *index) == (self.page, a.index) { - return self.xf.user_rect( - self.info, - self.page, - [a.rect[0] + *dx as f32, a.rect[1] + *dy as f32, a.rect[2] + *dx as f32, a.rect[3] + *dy as f32], - ); + let r = a.shown_rect(self.rotation()); + return self.xf.user_rect(self.info, self.page, [r[0] + *dx as f32, r[1] + *dy as f32, r[2] + *dx as f32, r[3] + *dy as f32]); } match (gesture, pointer) { (Some(Gesture::Move { page, index, from }), Some(p)) if (*page, *index) == (self.page, a.index) => r.translate(p - *from), @@ -1091,7 +1094,7 @@ fn select_input( let from_rect = cx.screen_rect(a); let shift = ui.input(|i| i.modifiers.shift); let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift)); - let rect = cx.rect_to_user(r); + let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation()); if r.width() >= 4.0 && r.height() >= 4.0 { view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect }); } @@ -1131,7 +1134,7 @@ fn select_input( cv.selected = Some((cx.page, a.index)); cv.composer = Some(Composer { page: cx.page, - at: [a.rect[2] as f64, a.rect[3] as f64], + at: edit_anchor(cx.info, a), kind: ComposerKind::Edit(a.index), text: a.contents.clone().unwrap_or_default(), focus: true, @@ -1332,6 +1335,15 @@ fn erase(cx: &PageCx<'_>, path: &[[f64; 2]]) -> Option { Some(Edit::Batch { label: "Erase".into(), edits }) } +/// Where the composer for editing `a` opens: the top-right corner of the comment as drawn on the +/// displayed page. +fn edit_anchor(info: &DocInfo, a: &Annotation) -> [f64; 2] { + let Some(p) = info.pages.get(a.page) else { return [f64::from(a.rect[2]), f64::from(a.rect[3])] }; + let r = a.shown_rect(p.rotation); + let (u, v) = (p.user_to_view(r[0], r[1]), p.user_to_view(r[2], r[3])); + p.view_to_user(u[0].max(v[0]), u[1].min(v[1])).map(f64::from) +} + fn new_comment(cx: &PageCx<'_>, tool: CommentTool, shape: Shape, contents: String) -> Edit { Edit::AddAnnotation(NewAnnotation { page: cx.page, shape, style: cx.prefs.style(tool), contents, author: cx.prefs.author.clone() }) } @@ -1588,7 +1600,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo if ui.add_enabled(allowed, egui::Button::new(tl!("Edit text…"))).clicked() { view.comments.composer = Some(Composer { page, - at: [a.rect[2] as f64, a.rect[3] as f64], + at: edit_anchor(info, a), kind: ComposerKind::Edit(index), text: a.contents.clone().unwrap_or_default(), focus: true, diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index c5fbbd0ce3..482d40e084 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -255,7 +255,8 @@ pub(crate) fn show( continue; } let thread = replies_of(a); - if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) { + let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation); + if let Some(e) = card(ui, t, a, rotation, &thread, view, prefs, allowed, nav) { edit = Some(e); } } @@ -267,6 +268,8 @@ fn card( ui: &mut egui::Ui, t: &Tokens, a: &Annotation, + // The clockwise `/Rotate` of the comment's page. + rotation: u16, thread: &[&Annotation], view: &mut DocView, prefs: &CommentPrefs, @@ -430,7 +433,7 @@ fn card( if click.clicked() { view.comments.selected = Some(key); view.comments.reply.clear(); - *nav = Some(Nav::Flash(a.page, a.rect)); + *nav = Some(Nav::Flash(a.page, a.shown_rect(rotation))); } if click.double_clicked() && allowed { view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default())); diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 914bc281b8..f479aebe43 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -488,6 +488,38 @@ fn sticky_note_composer_posts_and_returns_to_select() { assert_eq!(comments(&h).len(), 1); } +/// #2583: a sticky note is saved NoRotate, so on a turned page its icon is drawn upright from +/// where it was placed (right and down), not where its `/Rect` lies as displayed. The Select tool +/// picks it where it is drawn. +#[test] +fn a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn() { + let mut h = harness(|app| { + app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + app.set_option("quick", "note").unwrap(); + }); + settle(&mut h); + let page = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); + // Screen pixels per point of the displayed page (300 × 200 pt turned to 200 × 300). + let k = page.width() / 200.0; + let placed = page.left_top() + egui::vec2(100.0 * k, 60.0 * k); + click(&mut h, placed); + field(&h, "Add a comment").type_text("Turned"); + h.run_steps(2); + h.get_by_label("Post").click(); + h.run_steps(3); + settle(&mut h); + let c = comments(&h); + assert_eq!(c.len(), 1); + assert_eq!((c[0].subtype.as_str(), c[0].no_rotate), ("Text", true)); + assert_eq!(h.state().quick_tool, QuickTool::Select); + // Where the note's /Rect lies as displayed, left of where it was placed: nothing. + click(&mut h, placed + egui::vec2(-10.0 * k, 10.0 * k)); + assert_eq!(h.state().views[0].comments.selected, None); + // On the icon, drawn right and down from where it was placed. + click(&mut h, placed + egui::vec2(10.0 * k, 10.0 * k)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0))); +} + #[test] fn comments_are_selected_moved_and_deleted_with_the_select_tool() { let mut h = harness(|app| app.set_option("quick", "square").unwrap()); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94f..72ee77962f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3572,8 +3572,8 @@ milestone = "M5" status = "shipped" commands = ["comment.note"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"] -notes = "our own Comment/Note icon drawings, with a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline" +evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages", "crates/render/src/raster.rs::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages", "crates/ui-egui/tests/comments.rs::a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn"] +notes = "our own Comment/Note icon drawings, with a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline. Notes are saved with the NoRotate flag, and it is honoured: on a page with /Rotate the icon stays upright with its top-left where it was placed, and is selected and hovered there (#2583). Missing: NoZoom (the icon scales with the zoom); printing and flattening still place the icon in its /Rect, turned with the page" [[feature]] id = "comment.highlight" diff --git a/vendor/README.md b/vendor/README.md index 62be4cad9b..6a815d22b3 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -9,7 +9,7 @@ A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (37) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (37) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` | | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index fe08fc284b..a74f417f38 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -21,7 +21,7 @@ use hayro_syntax::object::dict::keys::{ ACTUAL_TEXT, ANNOTS, AP, AS, C, CA, F, MCID, N, OC, QUADPOINTS, RECT, SUBTYPE, }; use hayro_syntax::object::{Array, Dict, Object, Rect, Stream, dict_or_stream}; -use hayro_syntax::page::{ContentReader, Page, Resources}; +use hayro_syntax::page::{ContentReader, Page, Resources, Rotation}; use hayro_syntax::MAX_DECODED_STREAM; use kurbo::{Affine, BezPath, Point, Shape}; use smallvec::smallvec; @@ -249,6 +249,13 @@ pub fn interpret_page<'a>( annot_rect.x0 - sx * transformed_rect.x0, annot_rect.y0 - sy * transformed_rect.y0, ]); + // PdfCraft patch (37): NoRotate (bit 5) keeps the appearance upright as the page is + // displayed (ISO 32000-2 §12.5.3). + let affine = if flags & 16 != 0 && no_rotate_applies(&annot) { + no_rotate_turn(page.rotation(), &annot_rect) * affine + } else { + affine + }; // PdfCraft patch: a /BBox or /Matrix that collapses the appearance box to a line // or a point leaves nothing to map onto /Rect: the scales above divide by zero, @@ -282,6 +289,31 @@ pub fn interpret_page<'a>( } } +/// PdfCraft patch (37): whether the NoRotate flag turns this annotation's appearance. Form fields +/// and links are left in `/Rect`: PdfCraft takes their click targets from it. +fn no_rotate_applies(annot: &Dict<'_>) -> bool { + !matches!( + annot.get::>(SUBTYPE).as_deref(), + Some(b"Widget" | b"Link") + ) +} + +/// PdfCraft patch (37): the user-space turn that keeps a NoRotate annotation upright on a page +/// with `/Rotate`. The page is shown turned clockwise, so the appearance (already fitted to +/// `/Rect`) is turned as far counterclockwise about the rectangle's upper-left corner, which stays +/// where it is on the page (ISO 32000-2 §12.5.3). Its width then runs across the displayed page +/// and its height down from that corner. +fn no_rotate_turn(rotation: Rotation, rect: &kurbo::Rect) -> Affine { + let (cos, sin) = match rotation { + Rotation::None => return Affine::IDENTITY, + Rotation::Horizontal => (0.0, 1.0), + Rotation::Flipped => (-1.0, 0.0), + Rotation::FlippedHorizontal => (0.0, -1.0), + }; + let (x, y) = (rect.x0.min(rect.x1), rect.y0.max(rect.y1)); + Affine::new([cos, sin, -sin, cos, x - (cos * x - sin * y), y - (sin * x + cos * y)]) +} + /// PdfCraft patch: the normal appearance of a Highlight annotation that has none (ISO 32000-2 /// §12.5.6.10), drawn as PdfCraft draws its own highlights (pdfcraft-annot): each /QuadPoints /// quadrilateral filled with /C at /CA opacity, blended with Multiply so the marked-up content From 1d477655b5e65bc3f244a430007cd7e60cafa610 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:23:19 +0300 Subject: [PATCH 014/517] test(edit): cover fallback budget and weight cache (#2592) --- ROADMAP.md | 1 + crates/edit/src/tests.rs | 79 +++++++++++++++++++++++++++++++++ crates/edit/src/text_regions.rs | 9 ++-- docs/roadmap.md | 2 +- parity/acrobat-features.toml | 2 +- 5 files changed, 88 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 577370004e..c6a99283fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,6 +159,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 (M7, #3000 review):** Added mutation-checked regressions for the styled fallback budget (64 distinct glyphs accepted, 65 refused before any objects are added when the internal batch size is one) and the same character in regular/bold resources sharing a BaseFont but differing in descriptor weight. Ignoring either guard/key component makes its test fail. The internal budget scales with batch size; production stays at 15,360 glyphs. Estimates unchanged. - **2026-10-17 (M7, #2592):** Styled paragraph edits share Type 3 fallback fonts by actual face, up to 240 glyphs each, instead of writing one font per new character. Consecutive equal-font/style glyphs form one run; Latin source fonts, Japanese serif/sans and weight choices, alignment, save/reopen and undo/redo have regressions. A 300-glyph replacement spans two fonts; unsupported glyphs are refused before fallback objects are added. The styled paragraph has a 15,360-distinct-fallback-glyph budget. No wider glyph coverage, mixed contextual shaping, single-style change or stale-font cleanup is claimed. 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. diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index a8c0b23493..e48d148f32 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1256,6 +1256,85 @@ fn styled_fallback_batches_keep_face_choices_and_range_styles() { assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), "見見見ш見шLatin"); } +#[test] +fn styled_fallback_budget_is_enforced_before_objects_are_written() { + let glyphs = covered_japanese_chars(65); + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original) Tj ET"); + let original = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let snapshot = doc.clone(); + let style = text::BlockStyle { width: Some(14000.0), ..Default::default() }; + let over_budget = format!("{glyphs}\nLatin"); + let err = text::rewrite_block_with_limit(&mut doc, 0, 0, Some(&over_budget), &style, 1).unwrap_err().to_string(); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original, "refusal must add no objects"); + assert_eq!(write_incremental(&snapshot, &SaveOptions::default()).unwrap(), original); + if without_craft_fonts("styled_fallback_budget_is_enforced_before_objects_are_written") { + assert!(err.contains("CRAFT_FONTS_DIR"), "{err}"); + return; + } + assert!(err.contains("more than 64 fallback glyphs"), "{err}"); + // One glyph per font gives a small, real budget of 64 distinct glyphs. At the + // boundary a repeated glyph is free: this is not a limit on text length. + let at_budget: String = glyphs.chars().take(64).chain(glyphs.chars().take(1)).collect(); + text::rewrite_block_with_limit(&mut doc, 0, 0, Some(&format!("{at_budget}\nLatin")), &style, 1).unwrap(); + let doc = reopen(&doc); + assert_eq!(fallback_fonts(&doc).len(), 64); + assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), format!("{at_budget}Latin")); +} + +#[test] +fn styled_fallback_same_base_font_and_character_keep_both_weights() { + if without_craft_fonts("styled_fallback_same_base_font_and_character_keep_both_weights") { + return; + } + if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") { + eprintln!("skipping: no bold Gothic face"); + return; + } + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (alpha ) Tj /F2 12 Tf (beta ) Tj ET"); + let source = doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_dict().unwrap().clone(); + for (object, weight) in [(5, 400), (6, 700)] { + let mut font = source.clone(); + // The same BaseFont can have different descriptor weights. Changing a + // RangeStyle family would also rename BaseFont and miss this cache key. + font.set(b"BaseFont".to_vec(), Object::name("NeutralFace")); + let mut descriptor = Dict::new(); + descriptor.set(b"FontName".to_vec(), Object::name("NeutralFace")); + descriptor.set(b"FontWeight".to_vec(), Object::Int(weight)); + font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + doc.set(pdfcraft_cos::ObjRef::new(object, 0), Object::Dict(font)); + } + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].base_font, lines[1].base_font); + assert_eq!((lines[0].bold, lines[1].bold), (false, true)); + // Unchanged words anchor each inserted copy of the same letter in its own source style. + let block = text::text_blocks(&doc, 0).unwrap().remove(0); + let mut inherited = Vec::new(); + for (range, source) in block.edited_spans("alpha ш beta ш") { + for _ in "alpha ш beta ш".get(range).unwrap().chars().filter(|ch| *ch == 'ш') { + inherited.push(lines[source].bold); + } + } + assert_eq!(inherited, [false, true], "the fixture must exercise both weights of the same BaseFont"); + text::replace_block(&mut doc, 0, 0, "alpha ш beta ш").unwrap(); + let doc = reopen(&doc); + let fonts = fallback_fonts(&doc); + let mut names: Vec<_> = fonts.iter().map(|(_, f)| pdfcraft_fonts::pdf::Metrics::from_dict(&doc, f).base_font).collect(); + names.sort(); + assert_eq!(names, ["BIZUDPGothic-Bold", "BIZUDPGothic-Regular"]); + let outlines: Vec<_> = fonts + .iter() + .map(|(_, f)| { + let procs = doc.dict(f.get(b"CharProcs").unwrap()).unwrap(); + assert_eq!(procs.len(), 1, "each weight needs just the shared letter"); + stream_bytes(&doc, procs.iter().next().unwrap().1).unwrap() + }) + .collect(); + assert_ne!(outlines[0], outlines[1], "weight must change the outline, not only the descriptor"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::(), "alpha ш beta ш"); +} + #[test] fn styled_fallback_shards_keep_text_advances_and_alignment() { if without_craft_fonts("styled_fallback_shards_keep_text_advances_and_alignment") { diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 5078b3d67b..3ebdd00360 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -3,7 +3,9 @@ use super::*; // The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces. -const MAX_STYLED_FALLBACK_GLYPHS: usize = MAX_TYPE3_GLYPHS * 64; +// A glyph budget worth 64 full batches (15,360 glyphs with the production batch size). +// Lowering the internal batch size for tests lowers the budget by the same ratio. +const MAX_STYLED_FALLBACK_BATCHES: usize = 64; struct FallbackGroup { face: &'static CraftFont, @@ -405,6 +407,7 @@ pub(super) fn rewrite_styled_block( let mut fallback_names: std::collections::BTreeMap<(String, bool, char), usize> = std::collections::BTreeMap::new(); let mut fallback_glyphs = 0_usize; let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); + let fallback_budget = per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES); let mut full_faces: HashMap = HashMap::new(); let mut programs: HashMap>>> = HashMap::new(); for (range, index) in styled_spans { @@ -487,9 +490,9 @@ pub(super) fn rewrite_styled_block( }; let group = fallback_groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; if group.seen.insert(ch) { - if fallback_glyphs >= MAX_STYLED_FALLBACK_GLYPHS { + if fallback_glyphs >= fallback_budget { return Err(EditError::Invalid(format!( - "the replacement needs more than {MAX_STYLED_FALLBACK_GLYPHS} fallback glyphs; edit a smaller paragraph or load a complete font" + "the replacement needs more than {fallback_budget} fallback glyphs; edit a smaller paragraph or load a complete font" ))); } let (path, width) = type3_path(group.face, ch)?; diff --git a/docs/roadmap.md b/docs/roadmap.md index 356634bb40..ef03aca8f7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -120,7 +120,7 @@ total matches the parity estimate. | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | | 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: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | -| 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). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks #2592: styled fallback fonts batch up to 240 glyphs per actual face, retaining source styles; glyph coverage and complex mixed runs remain limited. | +| 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). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks #2592: styled fallback fonts batch up to 240 glyphs per actual face, retaining source styles; glyph coverage and complex mixed runs remain limited. #3000 review: budget boundary/atomic refusal and same-BaseFont descriptor weights have mutation-checked regressions. | | 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). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | 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), watermark print/screen visibility and repeat | | 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. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index fece02ef5e..3ae80e3089 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1866,7 +1866,7 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/automation/tests/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen"] +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen"] notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md" [[feature]] From ee780df46e89552ed792f0938d772bbc327f1c7b Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Sat, 17 Oct 2026 15:45:38 +0200 Subject: [PATCH 015/517] fix(ui): the back arrows leave Edit as the Edit tab does The tool panel's back arrow (Back to all tools, in the Edit panel among others) and Organize pages' back arrow set the workspace to All tools directly. Pressed from the Edit tab, they left Edit text & images on under All tools, every paragraph still boxed, and a paragraph open in the editor still open over the page with its edit not applied. Both arrows now go through enter_mode, as the mode bar and the Read mode command do (#3014). Organize calls it once the panel's action has been handed to the view, since the view borrows the app until then. Test: editing.rs::the_edit_panel_back_arrow_applies_an_open_paragraph_and_puts_edit_text_down Test: editing.rs::the_organize_back_arrow_applies_an_open_paragraph_and_puts_edit_text_down --- crates/ui-egui/src/lib.rs | 10 ++-- crates/ui-egui/src/organize_panel.rs | 6 ++- crates/ui-egui/src/panels.rs | 2 +- crates/ui-egui/tests/editing.rs | 69 ++++++++++++++++++++++------ 4 files changed, 67 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 27db0e98cc..1c10a0a83c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2149,11 +2149,11 @@ impl PdfCraftApp { } /// Changes the workspace and the tool that goes with it. The mode bar (through - /// [`Self::select_mode`]) and the Read mode command both come here, so they leave Edit alike. - /// Edit turns on Edit text & images at once, as Acrobat does: every paragraph and image on the - /// page gets its box and is editable with one click. Leaving Edit puts the editing tools down - /// again and applies a paragraph still open in the editor. - fn enter_mode(&mut self, mode: Mode) { + /// [`Self::select_mode`]), the Read mode command and the tool panels' back arrows all come + /// here, so they leave Edit alike. Edit turns on Edit text & images at once, as Acrobat does: + /// every paragraph and image on the page gets its box and is editable with one click. Leaving + /// Edit puts the editing tools down again and applies a paragraph still open in the editor. + pub(crate) fn enter_mode(&mut self, mode: Mode) { self.mode = mode; if mode == Mode::Edit { self.quick_tool = QuickTool::EditText; diff --git a/crates/ui-egui/src/organize_panel.rs b/crates/ui-egui/src/organize_panel.rs index a4c2e8cf7b..858e4b8c1b 100644 --- a/crates/ui-egui/src/organize_panel.rs +++ b/crates/ui-egui/src/organize_panel.rs @@ -166,7 +166,6 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } else if back { view.organize = false; app.left = LeftPanel::AllTools; - app.mode = crate::Mode::AllTools; } match act { Some(Act::Edit(edit)) => view.pending_edit = Some(*edit), @@ -174,6 +173,11 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { Some(Act::Command(command)) => app.run_command(command), None => {} } + // Leaving the workspace needs the whole app, so it waits until the view is done with: leaving + // Edit puts Edit text down and applies a paragraph still open on the page. + if back && !close { + app.enter_mode(crate::Mode::AllTools); + } } /// Whether a row works in this build. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d691488076..38d5dcd379 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -147,7 +147,7 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static let (back, close) = panel_header(ui, t, g.label, true); if back { app.left = LeftPanel::AllTools; - app.mode = crate::Mode::AllTools; + app.enter_mode(crate::Mode::AllTools); } if close { app.left_open = false; diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index fb0e750282..46bfc24f43 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2629,17 +2629,13 @@ fn editing_existing_text_in_place() { assert_eq!(texts_of(s, 0), ["Chapter One"], "the page shows it"); } -/// The Read mode command (View ▸ Read mode, its shortcut, the palette) leaves Edit as the Edit tab -/// does when another tab is chosen (#2241): Edit text & images is put down, and a paragraph still -/// open in the editor is applied instead of staying open, editable, over the page in Read mode. -#[test] -fn the_read_mode_command_applies_an_open_paragraph_and_puts_edit_text_down() { +/// Under the Edit tab, opens the line "Page 1" (at (20, 150) on a 200 × 300 page, 24 pt) in the +/// paragraph editor and retypes it as "Chapter One", leaving the editor open. +fn edit_tab_with_an_open_paragraph(h: &mut Harness<'static, PdfCraftApp>) { use pdfcraft_ui_egui::{Mode, QuickTool}; - let mut h = harness(1, |_| {}); h.get_by_label("Edit").click(); h.run_steps(3); assert_eq!((h.state().mode, h.state().quick_tool), (Mode::Edit, QuickTool::EditText)); - // Open the line "Page 1" (at (20, 150) on a 200 × 300 page, 24 pt) and retype it. let r = h.state().views[0].page_screen_rect(0).expect("on screen"); let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); h.hover_at(at); @@ -2651,18 +2647,65 @@ fn the_read_mode_command_applies_an_open_paragraph_and_puts_edit_text_down() { assert_eq!(h.state().views[0].line_editor.as_ref().expect("the line opens for editing").text, "Page 1"); h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Chapter One".into(); h.run_steps(1); - assert!(h.state_mut().execute("view.read_mode")); - h.run_steps(3); - let s = h.state(); - assert_eq!(s.mode, Mode::Read); - assert_eq!(s.quick_tool, QuickTool::Select, "Edit text & images is put down"); - assert!(s.views[0].line_editor.is_none(), "no paragraph editor is left open in Read mode"); +} + +/// Edit was left with the paragraph [`edit_tab_with_an_open_paragraph`] opened: Edit text & images +/// is put down, and the paragraph's edit is applied instead of its editor staying open over the page. +fn assert_edit_text_put_down_and_the_paragraph_applied(s: &PdfCraftApp) { + assert_eq!(s.quick_tool, pdfcraft_ui_egui::QuickTool::Select, "Edit text & images is put down"); + assert!(s.views[0].line_editor.is_none(), "no paragraph editor is left open"); let doc = s.session.get(s.views[0].id).unwrap(); assert_eq!(doc.can_undo(), Some("Edit text"), "the open paragraph's edit is applied"); assert_eq!(doc.text_lines(0)[0].text, "Chapter One"); assert_eq!(texts_of(s, 0), ["Chapter One"], "the page shows it"); } +/// The Read mode command (View ▸ Read mode, its shortcut, the palette) leaves Edit as the Edit tab +/// does when another tab is chosen (#2241): Edit text & images is put down, and a paragraph still +/// open in the editor is applied instead of staying open, editable, over the page in Read mode. +#[test] +fn the_read_mode_command_applies_an_open_paragraph_and_puts_edit_text_down() { + let mut h = harness(1, |_| {}); + edit_tab_with_an_open_paragraph(&mut h); + assert!(h.state_mut().execute("view.read_mode")); + h.run_steps(3); + assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Read); + assert_edit_text_put_down_and_the_paragraph_applied(h.state()); +} + +/// The Edit panel's back arrow leaves Edit for All tools as the mode bar does: Edit text & images +/// is put down and a paragraph still open in the editor is applied, instead of every paragraph +/// keeping its box, and the editor staying open, under All tools. +#[test] +fn the_edit_panel_back_arrow_applies_an_open_paragraph_and_puts_edit_text_down() { + use pdfcraft_ui_egui::{LeftPanel, Mode}; + let mut h = harness(1, |_| {}); + edit_tab_with_an_open_paragraph(&mut h); + h.get_by_label("Back to all tools").click(); + h.run_steps(3); + assert_eq!((h.state().mode, h.state().left), (Mode::AllTools, LeftPanel::AllTools)); + assert_edit_text_put_down_and_the_paragraph_applied(h.state()); +} + +/// Organize pages, opened under the Edit tab, leaves Edit the same way through its own back arrow. +#[test] +fn the_organize_back_arrow_applies_an_open_paragraph_and_puts_edit_text_down() { + use pdfcraft_ui_egui::{LeftPanel, Mode}; + let mut h = harness(1, |_| {}); + edit_tab_with_an_open_paragraph(&mut h); + // What the Edit panel's Organize pages row runs. + assert!(h.state_mut().execute("page.organize")); + h.run_steps(3); + assert!(h.state().views[0].organize, "the page grid opens"); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph is still open behind the grid"); + h.get_by_label("Back to all tools").click(); + h.run_steps(3); + let s = h.state(); + assert_eq!((s.mode, s.left), (Mode::AllTools, LeftPanel::AllTools)); + assert!(!s.views[0].organize, "the grid closes"); + assert_edit_text_put_down_and_the_paragraph_applied(s); +} + #[test] fn text_tool_clicking_source_text_edits_instead_of_adding_a_copy() { let mut h = harness(1, |_| {}); From 0f28c6a9029997fd8bead65e6cefce6dc708f6a3 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Sun, 18 Oct 2026 18:02:10 +0800 Subject: [PATCH 016/517] M11: target PDF version and object streams option in PDF Optimizer (#3090) --- crates/automation/src/lib.rs | 10 ++- crates/automation/src/tools.rs | 4 +- crates/automation/tests/automation.rs | 12 ++++ crates/cos/src/document.rs | 5 ++ crates/engine/src/optimizer.rs | 6 +- crates/optimize/src/lib.rs | 91 +++++++++++++++++++++++++++ crates/optimize/tests/optimize.rs | 75 +++++++++++++++++++++- crates/ui-egui/src/optimize_ui.rs | 24 ++++++- crates/ui-egui/tests/create.rs | 13 ++++ parity/acrobat-features.toml | 20 +++--- 10 files changed, 244 insertions(+), 16 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 88856305b2..eb320da764 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1059,7 +1059,7 @@ impl Automation { } fn doc_optimize(&mut self, a: &Args) -> Result { - use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset}; + use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset}; let id = self.doc(a)?.id; let before = self.doc(a)?.bytes.len(); let path = self.resolve(a.str("path")?, true)?; @@ -1067,6 +1067,10 @@ impl Automation { None => Preset::Standard.settings(), Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(), }; + if let Some(c) = a.opt_str("compatibility")? { + settings.compatibility = Compatibility::from_name(c) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown compatibility {c:?} (retain, 1.4, 1.5, 1.6, 1.7, 2.0)")))?; + } let images = |key: &str, base: ImageSettings| -> Result { let Some(v) = a.get(key) else { return Ok(base) }; let mut s = base; @@ -1100,6 +1104,7 @@ impl Automation { ("discard_tags", &mut settings.discard_tags), ("discard_print_settings", &mut settings.discard_print_settings), ("flate_unencoded", &mut settings.flate_unencoded), + ("object_streams", &mut settings.object_streams), ("remove_invalid_links", &mut settings.remove_invalid_links), ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), ] { @@ -1122,9 +1127,12 @@ impl Automation { }; let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?; write_atomic(&path, &bytes)?; + let pdf_version = std::str::from_utf8(bytes.get(5..8).unwrap_or_default()).unwrap_or_default(); let o = &r.optimize; Ok(json!({ "path": path.to_string_lossy(), + "pdf_version": pdf_version, + "object_streams": settings.effective_object_streams(), "bytes_before": before, "bytes_after": bytes.len(), "merged_images": o.merged_images, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 5e89aef78a..b967a526a8 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1303,13 +1303,14 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ "doc": doc(), "path": { "type": "string" }, "preset": { "type": "string", "enum": ["standard", "mobile"] }, + "compatibility": { "type": "string", "enum": ["retain", "1.4", "1.5", "1.6", "1.7", "2.0"] }, "merge_fragments": { "type": "boolean" }, "color": { "type": "object" }, "gray": { "type": "object" }, @@ -1318,6 +1319,7 @@ pub fn tools() -> Vec { "discard_tags": { "type": "boolean" }, "discard_print_settings": { "type": "boolean" }, "flate_unencoded": { "type": "boolean" }, + "object_streams": { "type": "boolean" }, "remove_invalid_links": { "type": "boolean" }, "remove_unreferenced_dests": { "type": "boolean" }, "discard": { "type": "array", "items": { "type": "string" } }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f38..b69a6b2070 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -4626,6 +4626,18 @@ fn optimizing_through_tools() { assert!(reduced["bytes_after"].as_u64().unwrap() < reduced["bytes_before"].as_u64().unwrap()); assert_eq!(reduced["written"], true); assert_eq!(std::fs::metadata(dir.join("reduced.pdf")).unwrap().len(), reduced["bytes_after"].as_u64().unwrap()); + + // Target PDF 1.4 (disables object streams) and PDF 2.0 (with object_streams toggled off). + let v14 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v14.pdf", "compatibility": "1.4" })); + assert_eq!((v14["pdf_version"].as_str(), v14["object_streams"].as_bool()), (Some("1.4"), Some(false)), "{v14}"); + let v14_bytes = std::fs::read(dir.join("v14.pdf")).unwrap(); + assert!(v14_bytes.starts_with(b"%PDF-1.4\n") && !v14_bytes.windows(7).any(|w| w == b"/ObjStm")); + + let v20 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v20.pdf", "compatibility": "2.0", "object_streams": false })); + assert_eq!((v20["pdf_version"].as_str(), v20["object_streams"].as_bool()), (Some("2.0"), Some(false)), "{v20}"); + assert!(std::fs::read(dir.join("v20.pdf")).unwrap().starts_with(b"%PDF-2.0\n")); + + assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "compatibility": "1.3" })), Err(ToolError::InvalidArgs(_)))); assert!(matches!( a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "color": { "compression": "gif" } })), Err(ToolError::InvalidArgs(_)) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index a0b1f56eeb..f016b2ae4d 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -805,6 +805,11 @@ impl Document { &self.version } + /// Set the PDF header version (`"1.4"`, `"1.7"`, `"2.0"`, …) written by a full save. + pub fn set_version(&mut self, version: impl Into) { + self.version = version.into(); + } + pub fn trailer(&self) -> &Dict { &self.trailer } diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index ee8c957f93..3616ecd47b 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -68,7 +68,11 @@ impl Session { cos: editor.cos.clone(), settings: settings.clone(), discard: discard.to_vec(), - opts: SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }, + opts: SaveOptions { + mod_date: self.now().map(pdfcraft_cos::pdf_date), + object_streams: settings.effective_object_streams(), + ..SaveOptions::default() + }, }) } } diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index c8b9b67690..1b84cadac0 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -38,12 +38,58 @@ pub use images::effective_resolutions; pub enum OptimizeError { #[error("the document has no page tree")] NoPages, + #[error("the document's encryption requires PDF {required} or later (chosen: PDF {chosen})")] + IncompatibleEncryption { required: &'static str, chosen: &'static str }, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), #[error("the optimization was cancelled")] Cancelled, } +/// The PDF version an optimized copy is made compatible with (Acrobat: Make compatible with). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Compatibility { + /// Keep the document's version (upgraded to 1.5 when object streams are written). + #[default] + Retain, + Pdf1_4, + Pdf1_5, + Pdf1_6, + Pdf1_7, + Pdf2_0, +} + +impl Compatibility { + pub const ALL: [Compatibility; 6] = + [Compatibility::Retain, Compatibility::Pdf1_4, Compatibility::Pdf1_5, Compatibility::Pdf1_6, Compatibility::Pdf1_7, Compatibility::Pdf2_0]; + + /// The name tools use (`"retain"`, `"1.4"`, `"1.5"`, `"1.6"`, `"1.7"`, `"2.0"`). + pub fn name(self) -> &'static str { + match self { + Compatibility::Retain => "retain", + Compatibility::Pdf1_4 => "1.4", + Compatibility::Pdf1_5 => "1.5", + Compatibility::Pdf1_6 => "1.6", + Compatibility::Pdf1_7 => "1.7", + Compatibility::Pdf2_0 => "2.0", + } + } + + pub fn from_name(name: &str) -> Option { + let trimmed = name.trim(); + let stripped = if trimmed.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("pdf ")) { &trimmed[4..] } else { trimmed }; + Compatibility::ALL.into_iter().find(|c| c.name().eq_ignore_ascii_case(stripped)) + } + + /// The three-character `%PDF-` header version, when a target version is chosen. + pub fn version(self) -> Option<&'static str> { + match self { + Compatibility::Retain => None, + _ => Some(self.name()), + } + } +} + /// Where an optimization is, reported to [`optimize_with_progress`]'s callback. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Stage { @@ -89,6 +135,8 @@ impl ImageSettings { #[derive(Clone, Debug, PartialEq)] pub struct Settings { + /// Target PDF version (`Retain` by default). + pub compatibility: Compatibility, /// Put page pictures drawn as many stacked fragments back together first ([`fragments`]). pub merge_fragments: bool, pub color: ImageSettings, @@ -100,16 +148,28 @@ pub struct Settings { /// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or /// ASCIIHex layer from streams that have one. pub flate_unencoded: bool, + /// Clean Up: pack non-stream objects into compressed object streams with a cross-reference + /// stream (PDF 1.5+, §7.5.7–7.5.8). Always off when `compatibility` is [`Compatibility::Pdf1_4`]. + pub object_streams: bool, /// Clean Up: remove links (and bookmark destinations) that point nowhere. pub remove_invalid_links: bool, /// Clean Up: remove named destinations nothing refers to. pub remove_unreferenced_dests: bool, } +impl Settings { + /// Whether `write_full` should pack non-stream objects into compressed object streams: + /// enabled when `object_streams` is set and the target version is not PDF 1.4 (§7.5.7). + pub fn effective_object_streams(&self) -> bool { + self.object_streams && self.compatibility != Compatibility::Pdf1_4 + } +} + impl Default for Settings { /// Acrobat's Reduce File Size choices. fn default() -> Self { Self { + compatibility: Compatibility::Retain, merge_fragments: true, color: ImageSettings::REDUCE, gray: ImageSettings::REDUCE, @@ -118,6 +178,7 @@ impl Default for Settings { discard_tags: false, discard_print_settings: false, flate_unencoded: true, + object_streams: true, remove_invalid_links: true, remove_unreferenced_dests: true, } @@ -186,6 +247,20 @@ pub struct Report { pub unused_xobjects: usize, } +/// The minimum PDF version required by `doc`'s active security handler, when it uses features +/// introduced after PDF 1.4 (crypt filters in 1.5, AES-128 in 1.6, AES-256 in 1.7). +fn required_encryption_version(doc: &Document) -> Option<&'static str> { + let h = doc.output_handler()?; + let d = h.dict(); + if d.v >= 5 || d.crypt_filters.iter().any(|(_, m)| *m == pdfcraft_cos::CryptMethod::Aes256) { + Some("1.7") + } else if d.v == 4 { + if d.crypt_filters.iter().any(|(_, m)| *m == pdfcraft_cos::CryptMethod::Aes128) { Some("1.6") } else { Some("1.5") } + } else { + None + } +} + /// Optimize `doc` in place. pub fn optimize(doc: &mut Document, settings: &Settings) -> Result { optimize_with_progress(doc, settings, &mut |_| true) @@ -195,6 +270,12 @@ pub fn optimize(doc: &mut Document, settings: &Settings) -> Result bool) -> Result { + if let Some(chosen) = settings.compatibility.version() + && let Some(required) = required_encryption_version(doc) + && chosen < required + { + return Err(OptimizeError::IncompatibleEncryption { required, chosen }); + } let mut report = Report::default(); let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?; if settings.merge_fragments { @@ -230,6 +311,16 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: } } let root = doc.root(); + if let Some(chosen) = settings.compatibility.version() { + doc.set_version(chosen); + if let Some(root) = root + && doc.get(root).as_dict().is_some_and(|c| c.contains(b"Version")) + { + doc.update_dict(root, |c| { + c.remove(b"Version"); + })?; + } + } if settings.discard_tags && let Some(root) = root && doc.get(root).as_dict().is_some_and(|c| c.contains(b"StructTreeRoot")) diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2a83e8d6af..e9689af52e 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; -use pdfcraft_optimize::{Compression, ImageSettings, Settings, SpaceCategory, audit_space, effective_resolutions, optimize}; +use pdfcraft_optimize::{ + Compatibility, Compression, ImageSettings, OptimizeError, Settings, SpaceCategory, audit_space, effective_resolutions, optimize, +}; fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option) -> ObjRef { // A smooth gradient with a little texture (photo-like). @@ -1022,3 +1024,74 @@ 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 compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption() { + let mut base = Document::new_empty(); + page(&mut base, &[], "BT /F1 12 Tf 72 720 Td (Compatibility test) Tj ET"); + let root = base.root().unwrap(); + base.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.6"))).unwrap(); + + for (compat, expected) in [ + (Compatibility::Pdf1_4, "1.4"), + (Compatibility::Pdf1_5, "1.5"), + (Compatibility::Pdf1_6, "1.6"), + (Compatibility::Pdf1_7, "1.7"), + (Compatibility::Pdf2_0, "2.0"), + ] { + let mut doc = base.clone(); + let settings = Settings { compatibility: compat, ..Settings::default() }; + optimize(&mut doc, &settings).unwrap(); + assert_eq!(doc.version(), expected); + assert!(!doc.get(root).as_dict().unwrap().contains(b"Version"), "overriding catalog /Version is cleared"); + let bytes = write_full(&doc, &SaveOptions { object_streams: settings.effective_object_streams(), ..SaveOptions::default() }).unwrap(); + assert!(bytes.starts_with(format!("%PDF-{expected}\n").as_bytes()), "{expected}"); + let has_objstm = bytes.windows(7).any(|w| w == b"/ObjStm"); + assert_eq!(has_objstm, compat != Compatibility::Pdf1_4, "{compat:?}"); + let info = pdfcraft_render::inspect(Arc::new(bytes), None).unwrap(); + assert_eq!(info.pdf_version, expected); + } + + // Disabling object_streams on PDF 1.7 writes a classic cross-reference table. + let mut doc = base.clone(); + let settings = Settings { compatibility: Compatibility::Pdf1_7, object_streams: false, ..Settings::default() }; + optimize(&mut doc, &settings).unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: settings.effective_object_streams(), ..SaveOptions::default() }).unwrap(); + assert!(bytes.starts_with(b"%PDF-1.7\n") && !bytes.windows(7).any(|w| w == b"/ObjStm")); + + // Encryption that requires a newer PDF version than the chosen compatibility is refused. + let mut aes256 = base.clone(); + aes256 + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "u", + owner_password: "o", + permissions: -1, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + assert!(matches!( + optimize(&mut aes256, &Settings { compatibility: Compatibility::Pdf1_6, ..Settings::default() }), + Err(OptimizeError::IncompatibleEncryption { required: "1.7", chosen: "1.6" }) + )); + optimize(&mut aes256, &Settings { compatibility: Compatibility::Pdf2_0, ..Settings::default() }).unwrap(); + assert_eq!(aes256.version(), "2.0"); + + let mut aes128 = base; + aes128 + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes128, + user_password: "u", + owner_password: "o", + permissions: -1, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + assert!(matches!( + optimize(&mut aes128, &Settings { compatibility: Compatibility::Pdf1_5, ..Settings::default() }), + Err(OptimizeError::IncompatibleEncryption { required: "1.6", chosen: "1.5" }) + )); + optimize(&mut aes128, &Settings { compatibility: Compatibility::Pdf1_6, ..Settings::default() }).unwrap(); +} diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 34188e184a..9660b0a924 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex}; use egui::{Align, Layout}; -use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset, QUALITIES, Settings}; +use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset, QUALITIES, Settings}; use pdfcraft_engine::optimizer::{OptimizeStage, Optimized}; use pdfcraft_engine::{DocId, EditError, Hidden, OptimizeReport}; @@ -172,6 +172,17 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo } } }); + ui.add_space(8.0); + ui.label(tl!("Compatibility")); + let compat_label = |c: Compatibility| match c { + Compatibility::Retain => tl!("Retain existing").to_string(), + _ => format!("PDF {}", c.name()), + }; + egui::ComboBox::from_id_salt("optimize-compatibility").selected_text(compat_label(d.settings.compatibility)).show_ui(ui, |ui| { + for c in Compatibility::ALL { + ui.selectable_value(&mut d.settings.compatibility, c, compat_label(c)); + } + }); }); ui.add_space(6.0); ui.horizontal_wrapped(|ui| { @@ -221,7 +232,16 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo ui.add(widgets::checkbox(&mut s.flate_unencoded, tl!("Use Flate to encode streams that are not encoded"))); ui.add(widgets::checkbox(&mut s.remove_invalid_links, tl!("Remove invalid links and bookmarks"))); ui.add(widgets::checkbox(&mut s.remove_unreferenced_dests, tl!("Remove unreferenced named destinations"))); - ui.add_enabled(false, widgets::checkbox(&mut true, tl!("Compress document structure (object streams)"))); + let mut object_streams = s.effective_object_streams(); + if ui + .add_enabled( + s.compatibility != Compatibility::Pdf1_4, + widgets::checkbox(&mut object_streams, tl!("Compress document structure (object streams)")), + ) + .changed() + { + s.object_streams = object_streams; + } ui.add_enabled(false, widgets::checkbox(&mut true, tl!("Remove unused objects and merge identical ones"))); } } diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs index ce2a5f37ed..4d80c4fe66 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/create.rs @@ -238,6 +238,19 @@ fn the_pdf_optimizer_dialog_picks_a_preset() { h.run_steps(2); assert!(!h.state().optimize_draft.settings.merge_fragments); h.get_by_value("Custom"); + + // Pick a target PDF version and toggle structure compression on Clean Up. + h.get_by_value("Retain existing").click(); + h.run_steps(4); + h.get_by_label("PDF 2.0").click(); + h.run_steps(2); + assert_eq!(h.state().optimize_draft.settings.compatibility, pdfcraft_engine::optimize::Compatibility::Pdf2_0); + h.get_by_label("Clean Up").click(); + h.run_steps(2); + assert!(h.state().optimize_draft.settings.object_streams); + h.get_by_label("Compress document structure (object streams)").click(); + h.run_steps(2); + assert!(!h.state().optimize_draft.settings.object_streams); } fn mixed_files(app: &PdfCraftApp) -> Vec<(String, Vec)> { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 157e9e368c..395b95bd7f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7245,11 +7245,11 @@ title = "Make compatible with a chosen PDF version" area = "K" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["optimize.advanced"] +tools = ["doc_optimize"] +evidence = ["crates/optimize/tests/optimize.rs::compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset"] +notes = "Retain existing (default), PDF 1.4, 1.5, 1.6, 1.7 or 2.0 in PDF Optimizer and doc_optimize; writes the chosen %PDF- header, clears any overriding catalog /Version, disables and unpacks object streams and cross-reference streams for PDF 1.4, and refuses when the document's encryption requires a newer PDF version (#3090)" [[feature]] id = "optimize.optimizer" @@ -7545,11 +7545,11 @@ title = "Compress document structure (object streams)" area = "K" tier = "P1" milestone = "M11" -status = "partial" -commands = ["file.save_as"] -tools = ["doc_save"] -evidence = ["crates/cos/tests/writer.rs::full_save_packs_objects_into_object_streams"] -notes = "on by default for full saves; no Optimizer option yet" +status = "shipped" +commands = ["file.save_as", "optimize.advanced"] +tools = ["doc_save", "doc_optimize"] +evidence = ["crates/cos/tests/writer.rs::full_save_packs_objects_into_object_streams", "crates/optimize/tests/optimize.rs::compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset"] +notes = "on by default for full saves and in PDF Optimizer ▸ Clean Up (and doc_optimize object_streams); turning it off or targeting PDF 1.4 unpacks object streams into standalone objects with a classic cross-reference table" [[feature]] id = "optimize.flate-unencoded" From eb85314b5b25cf4cb9c50575d7cf5f46df058092 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:41:08 +0300 Subject: [PATCH 017/517] M7: 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 445ffe118a..39931a02a6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,7 @@ # PdfCraft roadmap: milestones and what's next > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M7 #2592: styled paragraph fallback batches by actual face; 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 85e6d3307305c6514698e13b263ebfbf6782a2a5 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:43:06 +0300 Subject: [PATCH 018/517] M12: 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 27d5a87d31..56e2c90d7d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,7 @@ # PdfCraft roadmap: milestones and what's next > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2589: active XFA skips PDF-container script initialization; requested messages and static/ordinary PDF scripts remain; 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 3c94700447b994fdacf5f026bdf3b19507bc6537 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Sun, 18 Oct 2026 21:54:58 +0800 Subject: [PATCH 019/517] M11: document partial PDF version compatibility (#3227) --- ROADMAP.md | 2 ++ crates/optimize/README.md | 9 +++++++++ crates/optimize/src/lib.rs | 7 ++++++- docs/roadmap.md | 2 +- parity/acrobat-features.toml | 4 ++-- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ee84a11110..453346fa36 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -537,3 +537,5 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192). - **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged. + +- 2026-10-18: M11 / #3227: mark target-version compatibility partial; document the unchecked newer-content limitation. diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 32915af6bc..7a13e5bd8e 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -36,3 +36,12 @@ then this, then merges identical objects and writes a full, compressed save (`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a progress bar). JPEG decoding and encoding and resampling come from the `image` crate. + +## Target PDF version: partial compatibility + +The target selects the header version, clears the catalog `/Version`, controls object +streams (disabled for PDF 1.4), and checks encryption requirements. It does **not** +detect or convert content newer than the target: optional content, JPEG 2000 images, +16-bit images, OpenType fonts and PDF 2.0 features can remain. A PDF 1.4 header +therefore does not guarantee compatibility with PDF 1.4 readers or validators. +`optimize.compatibility-level` remains partial until content compatibility is checked. diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 1b84cadac0..917787e2cf 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -46,7 +46,11 @@ pub enum OptimizeError { Cancelled, } -/// The PDF version an optimized copy is made compatible with (Acrobat: Make compatible with). +/// The target header version and object-stream policy for an optimized copy. +/// +/// Partial compatibility only: encryption requirements are checked, but content newer than +/// the target is neither detected nor converted. A lower header does not guarantee that +/// older readers can read the document. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Compatibility { /// Keep the document's version (upgraded to 1.5 when object streams are written). @@ -270,6 +274,7 @@ pub fn optimize(doc: &mut Document, settings: &Settings) -> Result bool) -> Result { + // Lexical ordering is valid for these fixed versions with single-digit components. if let Some(chosen) = settings.compatibility.version() && let Some(required) = required_encryption_version(doc) && chosen < required diff --git a/docs/roadmap.md b/docs/roadmap.md index 55e898b283..e56c79add4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -132,7 +132,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). 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). Target-version compatibility is partial: newer content is not detected or converted. 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 | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 395b95bd7f..b7bba39cd7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7245,11 +7245,11 @@ title = "Make compatible with a chosen PDF version" area = "K" tier = "P1" milestone = "M11" -status = "shipped" +status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] evidence = ["crates/optimize/tests/optimize.rs::compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset"] -notes = "Retain existing (default), PDF 1.4, 1.5, 1.6, 1.7 or 2.0 in PDF Optimizer and doc_optimize; writes the chosen %PDF- header, clears any overriding catalog /Version, disables and unpacks object streams and cross-reference streams for PDF 1.4, and refuses when the document's encryption requires a newer PDF version (#3090)" +notes = "Retain existing (default), PDF 1.4, 1.5, 1.6, 1.7 or 2.0 in PDF Optimizer and doc_optimize; writes the chosen %PDF- header, clears any overriding catalog /Version, disables and unpacks object streams and cross-reference streams for PDF 1.4, and refuses when the document's encryption requires a newer PDF version (#3090). Partial compatibility only: content newer than the target is neither detected nor converted (including optional content, JPX images, 16-bit images, OpenType fonts and PDF 2.0 features); the chosen header does not guarantee compatibility with older readers" [[feature]] id = "optimize.optimizer" From 24932538211ff4f587a2ec497b3de99cbef77465 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:13:59 +0300 Subject: [PATCH 020/517] M12: reconcile current accessibility and XFA roadmap status --- docs/roadmap.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 56e2c90d7d..9dc6bb220c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -136,9 +136,8 @@ total matches the parity estimate. | 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 | -| 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). Done since: Active XFA skips PDF-container fallback scripts (#2589), preserving requested messages and saved scripts.
Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | +| 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; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Done since: Active XFA skips PDF-container fallback scripts (#2589), preserving requested messages and saved scripts.
Done since: Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: mixed-content and field autotag, heading/list/table/figure classification, 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. Done since: Active XFA skips PDF-container fallback scripts (#2589), preserving requested messages and saved scripts.
Missing: mixed-content and field autotag, heading/list/table/figure classification, 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. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). | From 2714f9e54f4bdae485a462066709618cdfad92a8 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Sun, 18 Oct 2026 23:09:09 +0800 Subject: [PATCH 021/517] automation: report effective validated PDF version in doc_optimize (#3090) --- crates/automation/src/lib.rs | 12 +++++++++++- crates/automation/tests/automation.rs | 10 ++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index eb320da764..ee0dc4cd7f 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1127,7 +1127,17 @@ impl Automation { }; let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?; write_atomic(&path, &bytes)?; - let pdf_version = std::str::from_utf8(bytes.get(5..8).unwrap_or_default()).unwrap_or_default(); + let valid_version = |v: &[u8]| matches!(v, [b'1'..=b'9', b'.', b'0'..=b'9']).then(|| std::str::from_utf8(v).ok()).flatten(); + let catalog_version = (settings.compatibility == Compatibility::Retain) + .then(|| { + let pw = self.doc(a).ok().and_then(|d| d.password.as_deref()); + let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?; + let root = doc.root()?; + doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned) + }) + .flatten(); + let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version); + let pdf_version = catalog_version.as_deref().or(header_version); let o = &r.optimize; Ok(json!({ "path": path.to_string_lossy(), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index b69a6b2070..55b08f6ba5 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -4637,6 +4637,16 @@ fn optimizing_through_tools() { assert_eq!((v20["pdf_version"].as_str(), v20["object_streams"].as_bool()), (Some("2.0"), Some(false)), "{v20}"); assert!(std::fs::read(dir.join("v20.pdf")).unwrap().starts_with(b"%PDF-2.0\n")); + // In retain mode, a catalog /Version override is reported as the effective PDF version. + let mut cos14 = pdfcraft_cos::Document::open(std::sync::Arc::new(v14_bytes)).unwrap(); + let root = cos14.root().unwrap(); + cos14.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::name("1.6"))).unwrap(); + let cat16 = pdfcraft_cos::write_full(&cos14, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap(); + std::fs::write(dir.join("cat16.pdf"), &cat16).unwrap(); + let cat_doc = ok(&mut a, "doc_open", json!({ "path": "cat16.pdf" }))["doc"].as_u64().unwrap(); + let retained = ok(&mut a, "doc_optimize", json!({ "doc": cat_doc, "path": "retained.pdf", "compatibility": "retain", "object_streams": false })); + assert_eq!(retained["pdf_version"].as_str(), Some("1.6"), "{retained}"); + assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "compatibility": "1.3" })), Err(ToolError::InvalidArgs(_)))); assert!(matches!( a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "color": { "compression": "gif" } })), From c392d93ba6f864dd3e35e91176ba4aba29b5afa1 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:06:32 -0400 Subject: [PATCH 022/517] M12: laying out a dynamic XFA form takes off the usage rights it voids The layout revision replaces the placeholder pages and adds the fields, so usage rights (/Perms /UR3) signed over the form as published no longer match it, and a viewer may then open the form with every field locked. write_form now takes them off with the helper strip already needed, and the layout report says so. --- crates/engine/src/tests.rs | 26 +++++++++++++++ crates/ui-egui/tests/ui_group/xfa.rs | 8 +++++ crates/xfa/src/fixtures.rs | 21 ++++++++++++ crates/xfa/src/lib.rs | 3 ++ crates/xfa/src/pdf.rs | 50 ++++++++++++++++++---------- crates/xfa/src/script.rs | 3 ++ crates/xfa/src/tests.rs | 31 +++++++++++++++++ parity/acrobat-features.toml | 6 ++-- 8 files changed, 128 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41dac..c89c5aac7c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4071,6 +4071,32 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { ); } +#[test] +fn a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided() { + let xdp = pdfcraft_xfa::fixtures::template(3); + let original = Arc::new(pdfcraft_xfa::fixtures::usage_rights_shell(&xdp, "", false)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, original.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + let warnings = &doc.xfa.as_ref().expect("laid out from the template").warnings; + assert!(warnings.iter().any(|w| w.contains("usage rights")), "the user is told: {warnings:?}"); + assert!(!doc.dirty, "laying out is still not an edit"); + s.apply(id, Edit::SetFieldValue { name: "familyName".into(), value: FieldValue::Text("Singh".into()) }).unwrap(); + let incremental = s.save_bytes(id).unwrap(); + assert!(incremental.starts_with(&original[..]), "still an incremental save"); + // Saved either way, the file no longer carries them, and keeps what was filled in. + for saved in [incremental, s.save_full_bytes(id).unwrap()] { + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let catalog = cos.get(cos.root().unwrap()).as_dict().cloned().unwrap(); + assert!(!catalog.contains(b"Perms"), "{catalog:?}"); + let again = s.open("saved.pdf", None, saved, None).unwrap(); + let d2 = s.get(again).unwrap(); + assert_eq!(d2.form.iter().find(|f| f.name == "familyName").unwrap().value, vec!["Singh".to_string()]); + let warnings = &d2.xfa.as_ref().expect("the layout is remembered").warnings; + assert!(!warnings.iter().any(|w| w.contains("usage rights")), "nothing left to say: {warnings:?}"); + } +} + #[test] fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))); diff --git a/crates/ui-egui/tests/ui_group/xfa.rs b/crates/ui-egui/tests/ui_group/xfa.rs index 2285f36e00..327b2368d9 100644 --- a/crates/ui-egui/tests/ui_group/xfa.rs +++ b/crates/ui-egui/tests/ui_group/xfa.rs @@ -62,6 +62,14 @@ fn a_dynamic_xfa_form_is_laid_out_and_filled() { h.get_by_label("Highlight fields"); } +#[test] +fn a_dynamic_xfa_form_says_when_laying_it_out_took_its_usage_rights_off() { + let xdp = pdfcraft_xfa::fixtures::template(2); + let h = open(pdfcraft_xfa::fixtures::usage_rights_shell(&xdp, "", false)); + h.get_by_label_contains("laid out from its template: 2 pages, 11 fields"); + h.get_by_label_contains("usage rights were removed"); +} + #[test] fn a_dynamic_xfa_form_says_its_page_is_a_placeholder() { // No fields, the form lives in the XFA packets (an array of name/stream pairs here), but diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 54bb9bc216..e3ab415e66 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -364,6 +364,27 @@ pub fn shell(xdp: &str) -> Vec { assemble(objs) } +/// [`shell`] with usage rights: the catalog's `/Perms` holds a `/UR3` signature (a stand-in +/// dictionary, nothing verifies it), beside `others` (`/DocMDP 6 0 R`, say: object 6 is a +/// signature dictionary too). `indirect` puts `/Perms` in an object of its own. +pub fn usage_rights_shell(xdp: &str, others: &str, indirect: bool) -> Vec { + let perms = format!("<< /UR3 6 0 R {others} >>"); + let entry = if indirect { "7 0 R".to_string() } else { perms.clone() }; + let catalog = format!("<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> /Perms {entry} >>"); + let mut objs: Vec> = vec![ + catalog.into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".to_vec(), + b"<< /Length 44 >>\nstream\nBT /F1 12 Tf 72 700 Td (Please wait...) Tj ET\nendstream".to_vec(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(), + b"<< /Type /Sig /Reference [<< /Type /SigRef /TransformMethod /UR3 >>] >>".to_vec(), + ]; + if indirect { + objs.push(perms.into_bytes()); + } + assemble(objs) +} + /// A dynamic form with two signature boxes of which `sign` has been signed: the file holds the /// field a signing application leaves (named by its SOM path, its value a signature dictionary) /// and the picture drawn in the box, a solid blue rectangle. diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index bf4414970e..8711df9fee 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -165,6 +165,9 @@ pub fn render_into(doc: &mut Document) -> Result { let written = pdf::write_form(doc, &form)?; let mut warnings = form.warnings; warnings.extend(written.warnings); + if written.usage_rights { + warnings.push(crate::pdf::USAGE_RIGHTS_REMOVED.into()); + } warnings.dedup(); Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings }) } diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index cca62e64e7..a84ed9c421 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -150,10 +150,16 @@ fn js_action(js: &str) -> Object { Object::Dict(a) } +/// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). +pub(crate) const USAGE_RIGHTS_REMOVED: &str = + "the form's usage rights were removed: laying it out voids them, and a form with void usage rights can open with every field locked"; + pub struct Written { pub pages: usize, pub fields: usize, pub warnings: Vec, + /// Usage rights were taken off the catalog (laying the form out voided them). + pub usage_rights: bool, } /// On a push button generated for an XFA image field: clicking it picks a picture (the forms @@ -774,10 +780,12 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result catalog_changed = true; } } - if catalog_changed { + // The pages and fields are new: usage rights signed over the form as it was are void now. + let usage_rights = drop_usage_rights(doc, &mut catalog); + if catalog_changed || usage_rights { doc.set(catalog_ref, Object::Dict(catalog)); } - Ok(Written { pages: count, fields: field_count, warnings }) + Ok(Written { pages: count, fields: field_count, warnings, usage_rights }) } /// What [`strip`] took out, for the user to be told (the packets held more than the fields). @@ -963,21 +971,7 @@ pub fn strip(doc: &mut Document) -> Result { None => catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)), } catalog.remove(b"NeedsRendering"); - // Reader usage rights are void once the form changed: Reader says so on opening the file - // and drops them, so they go now, with a note. - if let Some(perms) = catalog.get(b"Perms").map(|p| doc.resolve(p)) - && let Some(mut perms) = perms.as_dict().cloned() - && (perms.contains(b"UR3") || perms.contains(b"UR")) - { - perms.remove(b"UR3"); - perms.remove(b"UR"); - out.usage_rights = true; - if perms.is_empty() { - catalog.remove(b"Perms"); - } else { - catalog.set(b"Perms".to_vec(), Object::Dict(perms)); - } - } + out.usage_rights = drop_usage_rights(doc, &mut catalog); doc.set(root, Object::Dict(catalog)); // The packet streams are referenced from nowhere else: freed, so an incremental save // doesn't carry an unreachable copy of the data. @@ -1024,6 +1018,28 @@ fn unbound_data(doc: &Document) -> Option { Some(count) } +/// Take the usage rights (`/Perms /UR3`, or the older `/UR`) off a catalog, keeping the rest of +/// `/Perms` (a certification's `/DocMDP`). They are signed over the form as it was: once its +/// pages or fields change they are void, and a viewer may lock every field of a form whose +/// usage rights are void, so they go, and the caller says so. Whether there were any. +fn drop_usage_rights(doc: &mut Document, catalog: &mut Dict) -> bool { + let Some(entry) = catalog.get(b"Perms").cloned() else { return false }; + let Some(mut perms) = doc.resolve(&entry).as_dict().cloned() else { return false }; + if !perms.contains(b"UR3") && !perms.contains(b"UR") { + return false; + } + perms.remove(b"UR3"); + perms.remove(b"UR"); + match entry.as_ref() { + _ if perms.is_empty() => { + catalog.remove(b"Perms"); + } + Some(r) => doc.set(r, Object::Dict(perms)), + None => catalog.set(b"Perms".to_vec(), Object::Dict(perms)), + } + true +} + /// The layout a document already carries from an earlier pass, if any. pub fn existing_layout(doc: &Document) -> Option { let root = doc.root()?; diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index 17a0376641..a3ac812eba 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -576,6 +576,9 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result let written = crate::pdf::write_form(doc, &form)?; let mut warnings = form.warnings; warnings.extend(written.warnings); + if written.usage_rights { + warnings.push(crate::pdf::USAGE_RIGHTS_REMOVED.into()); + } warnings.dedup(); Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings }) } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 03eb2c67ad..3dd84222c2 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1961,6 +1961,37 @@ fn an_anchor_type_places_the_named_point_of_the_box_at_its_x_and_y() { assert!((field.rect.x - 6.0 * 72.0).abs() < 0.01 && (field.rect.y - 72.0).abs() < 0.01, "{:?}", field.rect); } +#[test] +fn laying_a_form_out_takes_off_the_usage_rights_it_voids() { + use crate::fixtures::usage_rights_shell; + let xdp = fields_template("
JP
"); + // What `/Perms` holds once the layout is saved and the file read again. + let laid_out = |bytes: Vec| { + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + let report = render_into(&mut doc).unwrap(); + let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let indirect = catalog.get(b"Perms").and_then(Object::as_ref).is_some(); + let perms = catalog.get(b"Perms").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()); + let said = report.warnings.iter().any(|w| w.contains("usage rights")); + (perms, indirect, said, report.warnings) + }; + // Usage rights alone: `/Perms` goes, and the user is told. + let (perms, _, said, warnings) = laid_out(usage_rights_shell(&xdp, "", false)); + assert!(perms.is_none(), "{perms:?}"); + assert!(said, "{warnings:?}"); + // Beside a certification, in an object of its own: only the usage rights go, and the + // object is rewritten where it is. + let (perms, indirect, said, warnings) = laid_out(usage_rights_shell(&xdp, "/DocMDP 6 0 R", true)); + let perms = perms.expect("the certification stays"); + assert!(perms.contains(b"DocMDP") && !perms.contains(b"UR3"), "{perms:?}"); + assert!(indirect, "/Perms is still its own object"); + assert!(said, "{warnings:?}"); + // A form without usage rights says nothing about them. + let (perms, _, said, warnings) = laid_out(shell(&xdp)); + assert!(perms.is_none() && !said, "{perms:?} {warnings:?}"); +} + #[test] fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { use crate::pdf::strip; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index b1805d5223..5f9fc9b9d5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -707,7 +707,7 @@ status = "planned" commands = [] tools = [] evidence = [] -notes = "preserve only" +notes = "preserve only. A dynamic XFA form PdfCraft lays out loses them: the layout voids them, so they are taken off with a note (form.xfa-dynamic)" # ================================================================================================== # B. Viewing and navigation @@ -5220,8 +5220,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"] -notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided"] +notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From 93544ef1bff769b2b722d43bbdc024e4484304b5 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:32:52 -0400 Subject: [PATCH 023/517] M12: the XFA notice bar starts its first layout note as a sentence Layout notes are lowercase clauses joined with semicolons, and the bar put the first right after the layout's own sentence ('11 fields. the form's usage rights...'). The first note now starts with a capital. --- crates/ui-egui/src/canvas.rs | 6 +++++- crates/ui-egui/tests/ui_group/xfa.rs | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ad74834656..1a412ff635 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4494,8 +4494,12 @@ fn notices( let mut text = crate::i18n::fmt(tl!("Dynamic XFA form laid out from its template: {pages}, {fields}."), &[("pages", &pages), ("fields", &fields)]); if !x.warnings.is_empty() { + // The notes are clauses ("a picture is left out"); the first starts a sentence here. + let notes = x.warnings.join("; "); + let mut chars = notes.chars(); text.push(' '); - text.push_str(&x.warnings.join("; ")); + text.extend(chars.next().into_iter().flat_map(char::to_uppercase)); + text.push_str(chars.as_str()); text.push('.'); } Some(("text-cursor-input", text, true)) diff --git a/crates/ui-egui/tests/ui_group/xfa.rs b/crates/ui-egui/tests/ui_group/xfa.rs index 327b2368d9..34bc52386c 100644 --- a/crates/ui-egui/tests/ui_group/xfa.rs +++ b/crates/ui-egui/tests/ui_group/xfa.rs @@ -66,8 +66,8 @@ fn a_dynamic_xfa_form_is_laid_out_and_filled() { fn a_dynamic_xfa_form_says_when_laying_it_out_took_its_usage_rights_off() { let xdp = pdfcraft_xfa::fixtures::template(2); let h = open(pdfcraft_xfa::fixtures::usage_rights_shell(&xdp, "", false)); - h.get_by_label_contains("laid out from its template: 2 pages, 11 fields"); - h.get_by_label_contains("usage rights were removed"); + // The note starts its own sentence after the layout's. + h.get_by_label_contains("laid out from its template: 2 pages, 11 fields. The form's usage rights were removed"); } #[test] From 703db23de301aa307b55bf235c3bc955462899d8 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 19 Oct 2026 16:46:35 +0100 Subject: [PATCH 024/517] M10: review of #1609: judge a page that is no scan again once the document changes CodeRabbit: Edit marked a page as tried before checking that it was a scan, so a page that was not one when Edit first looked (no picture over it yet) was never looked at again, even after a picture of text was added to it. A page found not to be a scan is now remembered with the document's edit generation and judged again only after the document changes (not every frame: listing a page's images parses it); a page whose recognition has started is still never started again, so Undo sticks. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas.rs | 4 ++++ crates/ui-egui/src/ocr_ui.rs | 13 ++++++++++--- crates/ui-egui/tests/ocr.rs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3ad4317974..6c6b9fb347 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -476,6 +476,9 @@ pub struct DocView { shown: bool, /// Pages that Edit already tried to turn into editable text (once each, so Undo sticks). pub(crate) auto_ocr_tried: std::collections::HashSet, + /// Pages Edit looked at and found not to be a scan, with the document's edit generation then: + /// they are looked at again only after the document changes (an image added, say). + pub(crate) auto_ocr_skipped: std::collections::HashMap, /// Turns wheel input into page turns in single-page view. wheel: crate::wheel_pager::WheelPager, /// Until this time, apply precise (touchpad) wheel input 1:1 and suppress egui's eased copy. @@ -760,6 +763,7 @@ impl DocView { zoom_anchor: None, shown: false, auto_ocr_tried: Default::default(), + auto_ocr_skipped: Default::default(), wheel: Default::default(), precise_scroll_until: 0.0, wheel_boost_x: Default::default(), diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 64d44e714a..0dcd5c9a6b 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -174,8 +174,8 @@ impl PdfCraftApp { /// while Edit is on (its tab, or the Edit text & images or Add text tool). The page must be a /// picture (an image covering at least half of it) with no text of its own: comments and text /// boxes over it do not count as text, but real page text does, and such a page (a flyer with a - /// background picture) is left alone. It is one undoable step, "Recognize text", and is not - /// run again for that page after Undo. + /// background picture) is left alone, and is judged again only when the document changes. It + /// is one undoable step, "Recognize text", and is not run again for that page after Undo. pub(crate) fn auto_ocr_scanned_page(&mut self) { // Edit is on when its panel is open (the Edit tab) or Edit text & images or Add text is picked, // the same test the page view uses for editing content. @@ -193,16 +193,23 @@ impl PdfCraftApp { if doc.read_only_reason.is_some() || !doc.allows_modification() { return; } + // A page found not to be a scan is judged again only once the document has changed, not + // every frame (listing its images parses the page). + let generation = doc.edit_generation(); + if self.views[vi].auto_ocr_skipped.get(&page) == Some(&generation) { + return; + } let Some(info) = doc.info.pages.get(page) else { return }; let page_area = f64::from(info.width) * f64::from(info.height); let covered = doc.page_images(page).iter().any(|i| { let [x0, y0, x1, y1] = i.visible_rect.unwrap_or(i.rect); page_area > 0.0 && ((x1 - x0) * (y1 - y0)).abs() >= 0.5 * page_area }); - self.views[vi].auto_ocr_tried.insert(page); if !covered || !pdfcraft_engine::ocr::available() { + self.views[vi].auto_ocr_skipped.insert(page, generation); return; } + self.views[vi].auto_ocr_tried.insert(page); // Only a page with no text of its own is read. Comments and text boxes over the picture do // not count as its text (a scan with a few comments is still a scan, as in Acrobat), but // a page with real text and a background picture is left alone. diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index 906551554d..5312096345 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -275,6 +275,37 @@ fn editing_a_page_with_text_over_a_background_picture_changes_nothing() { assert!(!h.state().session.get(id).unwrap().dirty, "the file is not marked modified"); } +/// A page that is not a scan when Edit looks at it is looked at again after the document changes: +/// adding a picture of text that covers it turns it into a scan, and Edit then reads it (and does +/// so once). +#[test] +fn a_page_that_becomes_a_scan_while_edit_is_on_is_recognized() { + if !pdfcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let png = { + let mut s = Session::new(); + let text = s.create_from_text("t", "Recognize these scanned words").unwrap(); + let id = s.open("text.pdf", None, text, None).unwrap(); + export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap() + }; + let blank = Session::new().create_blank(595.0, 842.0, 1).unwrap().to_vec(); + let mut h = edit_harness(blank); + let id = h.state().active_ids().unwrap().1; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "a blank page is no scan: nothing happened"); + // The same page, now with a picture of text over all of it. + h.state_mut() + .session + .apply(id, Edit::AddImage { page: 0, rect: Some([0.0, 0.0, 595.0, 842.0]), name: "scan.png".into(), bytes: std::sync::Arc::new(png) }) + .unwrap(); + h.run_steps(6); + assert!(h.state().ocr_run.is_none(), "finished"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); +} + #[test] fn editing_a_page_that_has_text_does_not_recognize_anything() { let mut h = edit_harness(text_page()); From 8a6d7dede7ff070baeb93210e065a4d4118262c0 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 19 Oct 2026 18:25:31 +0100 Subject: [PATCH 025/517] M10: review of #1609: Edit-triggered recognition says nothing when it reads nothing CodeRabbit: the recognition Edit starts used the same notifications as a run the user asked for, so turning Edit on for a page with text of its own (skipped by the run) showed "Recognizing text..." and then "Recognized 0 words on 0 pages; 1 page already had text". The run Edit starts is now marked automatic: it shows no progress toast, and no completion toast when it added no words. A run the user asks for, and an automatic run that did read words, notify as before. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ocr_ui.rs | 19 +++++++++++++------ crates/ui-egui/tests/ocr.rs | 3 +++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 0dcd5c9a6b..34f023d6dd 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -65,6 +65,9 @@ pub struct BatchProgress { pub struct OcrRun { pub doc: DocId, pub progress: Arc>, + /// Started by Edit on a scanned page, not asked for: it shows no progress, and says nothing when + /// it found no words (a page with text of its own is skipped, and Edit must not make noise). + pub automatic: bool, } pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { @@ -165,7 +168,7 @@ impl PdfCraftApp { OcrPages::Range => (d.from.saturating_sub(1)..d.to).collect(), }; let settings = d.settings(); - self.start_ocr_pages(id, &pages, settings); + self.start_ocr_pages(id, &pages, settings, false); } /// Edit on a scanned page: like Acrobat's "Converting scanned page to Editable Text and @@ -216,11 +219,11 @@ impl PdfCraftApp { let mut settings = self.ocr_draft.settings(); settings.skip_text_pages = true; settings.native_text_only = true; - self.start_ocr_pages(id, &[page], settings); + self.start_ocr_pages(id, &[page], settings, true); } /// Recognize `pages` (0-based; empty = all) of document `id` in the background. - fn start_ocr_pages(&mut self, id: DocId, pages: &[usize], settings: OcrSettings) { + fn start_ocr_pages(&mut self, id: DocId, pages: &[usize], settings: OcrSettings, automatic: bool) { let Some(job) = self.session.ocr_job(id, pages, settings) else { return }; let progress = Arc::new(Mutex::new(OcrProgress { total: job.pages.len(), ..Default::default() })); let p = progress.clone(); @@ -245,7 +248,7 @@ impl PdfCraftApp { } #[cfg(target_arch = "wasm32")] work(); - self.ocr_run = Some(OcrRun { doc: id, progress }); + self.ocr_run = Some(OcrRun { doc: id, progress, automatic }); self.poll_ocr(); } @@ -369,7 +372,7 @@ impl PdfCraftApp { } } let Some(run) = self.ocr_run.as_ref() else { return }; - let (doc, progress) = (run.doc, run.progress.clone()); + let (doc, progress, automatic) = (run.doc, run.progress.clone(), run.automatic); let Ok(mut s) = progress.lock() else { return }; let Some(result) = s.result.take() else { let msg = crate::i18n::fmt( @@ -377,7 +380,7 @@ impl PdfCraftApp { &[("d", &(s.done + 1).min(s.total.max(1)).to_string()), ("t", &s.total.max(1).to_string())], ); drop(s); - if self.toast.as_ref().is_none_or(|t| t.0 != msg) { + if !automatic && self.toast.as_ref().is_none_or(|t| t.0 != msg) { self.notify(msg); } if let Some(ctx) = &self.ctx { @@ -403,6 +406,10 @@ impl PdfCraftApp { { view.document_changed(&info); } + // An automatic run that found nothing (a page that has text of its own) says nothing. + if automatic && words == 0 { + return; + } let words_part = if words == 1 { crate::i18n::fmt(tl!("Recognized 1 word"), &[]) } else { diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index 5312096345..86740c4517 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -273,6 +273,9 @@ fn editing_a_page_with_text_over_a_background_picture_changes_nothing() { assert!(h.state().ocr_run.is_none()); assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "nothing was recognized or marked modified"); assert!(!h.state().session.get(id).unwrap().dirty, "the file is not marked modified"); + // And Edit says nothing about recognizing: no progress, no "0 words ... already had text". + let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(!toast.contains("ecogniz") && !toast.contains("already had text"), "{toast:?}"); } /// A page that is not a scan when Edit looks at it is looked at again after the document changes: From 0b3fa7afb5a9770b80024e3db9764adcb3aab887 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 19 Oct 2026 21:26:26 +0100 Subject: [PATCH 026/517] M10: review of #1609: no automatic recognition in Read mode or beneath a dialog CodeRabbit: Read mode leaves the Edit tool picked, so the recognition Edit starts could run in Read mode and change a scanned document; and it could run while a dialog or prompt was open, which PdfCraftApp::modal_open says nothing may act beneath. It now waits for Edit to be on outside Read mode with no modal open, and is tried again once they are gone. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ocr_ui.rs | 5 ++++- crates/ui-egui/tests/ocr.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 34f023d6dd..dcd3553756 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -184,7 +184,10 @@ impl PdfCraftApp { // the same test the page view uses for editing content. let editing = (self.left_open && self.left == crate::LeftPanel::Tool("edit")) || matches!(self.quick_tool, crate::QuickTool::EditText | crate::QuickTool::AddText); - if !editing || self.ocr_run.is_some() || self.ocr_batch.is_some() { + // Not in Read mode (which leaves the Edit tool picked but is not editing) and not beneath a + // dialog or prompt: nothing may change the document under those. It is tried again once + // they are gone. + if !editing || self.mode == crate::Mode::Read || self.modal_open() || self.ocr_run.is_some() || self.ocr_batch.is_some() { return; } let Some((vi, id)) = self.active_ids() else { return }; diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index 86740c4517..872768c549 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -309,6 +309,33 @@ fn a_page_that_becomes_a_scan_while_edit_is_on_is_recognized() { assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); } +/// Edit changes the document only while the user is editing: not in Read mode (which leaves the Edit +/// tool picked), and not beneath a dialog. The page is recognized once those are gone. +#[test] +fn edit_recognizes_a_scan_only_outside_read_mode_and_beneath_no_dialog() { + if !pdfcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let mut h = edit_harness(scan()); + let id = h.state().active_ids().unwrap().1; + let undo = |h: &Harness<'static, PdfCraftApp>| -> Option { h.state().session.get(id).unwrap().can_undo().map(String::from) }; + // Read mode with the Edit tool still picked. + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().set_option("mode", "read").unwrap(); + h.run_steps(5); + assert_eq!(undo(&h), None, "not in Read mode"); + // A dialog over the window. + h.state_mut().set_option("mode", "edit").unwrap(); + h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::JsConsole); + h.run_steps(5); + assert_eq!(undo(&h), None, "not beneath a dialog"); + // Both gone: it is read now. + h.state_mut().dialog = None; + h.run_steps(5); + assert_eq!(undo(&h).as_deref(), Some("Recognize text")); +} + #[test] fn editing_a_page_that_has_text_does_not_recognize_anything() { let mut h = edit_harness(text_page()); From 7956c6c1823f810dc0e3d920afa2081e675f7e80 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:28:10 +0200 Subject: [PATCH 027/517] Save commits the open Edit Text boxes before writing the file (#3496) --- crates/ui-egui/src/edit_text_ui.rs | 18 +++++++++++ crates/ui-egui/src/editing.rs | 50 +++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 66ad98ffe6..f9a028a0ef 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -1495,6 +1495,24 @@ mod tests { assert!(app.views[0].line_editor.is_some(), "the paragraph stays open for editing"); } + #[test] + fn save_applies_the_paragraph_open_in_the_editor() { + // Save wrote the file without the paragraph still open in the editor (#3496). + let (mut app, mut ed) = preview_app(); + ed.text = "Text preview".into(); + app.views[0].line_editor = Some(ed); + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-open-paragraph-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + app.save_override = Some(dir.join("out.pdf").to_string_lossy().into_owned()); + + assert!(app.save_active(crate::SaveTarget::InPlace)); + assert!(app.views[0].line_editor.is_none()); + let doc = app.session.get(app.views[0].id).unwrap(); + assert_eq!(doc.text_lines(0).iter().map(|l| l.text.as_str()).collect::(), "Text preview"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn text_background_is_cached_without_editing_and_released_on_cancel() { let (app, ed) = preview_app(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 91480ac7b5..6220dc0645 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -523,17 +523,31 @@ impl PdfCraftApp { self.notify(crate::content_ui::undrawable_message(c)); return false; } - let typing = self - .views - .get(i) - .is_some_and(|v| v.pending_edit.is_some() || v.forms.focus.is_some() || v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty())); + let typing = self.views.get(i).is_some_and(|v| { + v.pending_edit.is_some() + || v.forms.focus.is_some() + || v.content.draft.is_some() + || v.line_editor.as_ref().is_some_and(|ed| ed.has_changes()) + || v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty()) + }); if self.active != Some(i) { if !typing { return true; } self.active = Some(i); } - self.commit_form_typing() && self.commit_open_fill_text(id) + self.commit_form_typing() && self.commit_open_fill_text(id) && self.commit_open_text_edits(id) + } + + /// The Edit a PDF ▸ Text boxes (new text, and a paragraph open for editing) commit on + /// click-away. Save does that first, so the typed text is in the file instead of being left + /// open on the screen (#3496). + fn commit_open_text_edits(&mut self, id: pdfcraft_engine::DocId) -> bool { + let Some(doc) = self.session.get(id) else { return true }; + let Some(view) = self.views.iter_mut().find(|v| v.id == id) else { return true }; + let added = view.content.finish_draft(doc.added()); + let paragraph = view.line_editor.take_if(|ed| ed.has_changes()).and_then(crate::edit_text_ui::finished_edit); + added.into_iter().chain(paragraph).all(|edit| self.apply_edit(edit)) } /// The Fill & Sign type box commits on click-away. Save does that first, so the text is in @@ -1374,6 +1388,32 @@ mod tests { out } + #[test] + fn save_commits_the_open_text_box() { + let dir = staging_dir("open-text-box"); + let dest = dir.join("out.pdf"); + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("a.pdf", None, one_page_pdf()).unwrap(); + app.save_override = Some(dest.to_string_lossy().into_owned()); + let style = crate::content_ui::default_style(); + app.views[0].content.draft = Some(crate::content_ui::TextDraft { + page: 0, + index: None, + rect: [20.0, 20.0, 120.0, 40.0], + text: "Typed text".into(), + style, + focus: true, + }); + + assert!(app.save_active(SaveTarget::InPlace)); + let id = app.views[0].id; + let added = app.session.get(id).unwrap().added(); + assert_eq!(added.len(), 1, "the open text box was committed before the save"); + assert!(app.views[0].content.draft.is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn save_asks_where_to_keep_an_email_attachment_and_leaves_the_temporary_copy_alone() { let dir = staging_dir("mail-temp"); From 86e44dc6d53cd42d9159ba690a8fca7820ed8efe Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:33:17 -0400 Subject: [PATCH 028/517] M6: util.scand reads dates as another viewer does --- crates/js/src/formcalc.rs | 2 +- crates/js/src/lib.rs | 317 ++++++++++++++++++++++++++++++++++- crates/js/src/tests.rs | 179 ++++++++++++++++++++ crates/js/src/xfa.rs | 3 +- parity/acrobat-features.toml | 4 +- 5 files changed, 499 insertions(+), 6 deletions(-) diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index dc32d457bf..43f32a11b0 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -1924,7 +1924,7 @@ fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { } /// Number of days in month `m` (1-12) of year `y`, leap years included. -fn days_in(y: i64, m: u32) -> u32 { +pub(crate) fn days_in(y: i64, m: u32) -> u32 { match m { 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, 2 => 28, diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index d8d9fa8bac..0e7de9891f 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -14,7 +14,7 @@ //! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, //! `setFocus`. //! - `app` (`alert`, `beep`, `response`, `launchURL`, `viewerType`, `viewerVersion`, -//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`), `console` (`println`, +//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`, `scand`), `console` (`println`, //! `show`, `clear`), `display` and `color`. //! //! The engine is a sandbox: no file, network or timer access exists, and loops and recursion @@ -26,7 +26,7 @@ use std::cell::RefCell; use std::collections::BTreeSet; -use boa_engine::object::builtins::{JsArray, JsFunction}; +use boa_engine::object::builtins::{JsArray, JsDate, JsFunction}; use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer}; use boa_engine::property::Attribute; use boa_engine::{Context, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; @@ -802,6 +802,318 @@ fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Ok(s(&out)) } +/// A field of a `util.scand` format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DateUnit { + /// `yyyy`: four digits. + Year, + /// `yy`: two digits, read as 1950–2049. + ShortYear, + Month, + /// `mmm`, `mmmm`: a month's name or its three-letter abbreviation. + MonthName, + Day, + /// `ddd`, `dddd`: a weekday's name, read and not checked. + Weekday, + Hour, + /// `h`, `hh`: 0–12, with `tt` for the afternoon. + Hour12, + Minute, + Second, + /// `t`, `tt`: am or pm. + AmPm, +} + +impl DateUnit { + fn is_date(self) -> bool { + matches!(self, Self::Year | Self::ShortYear | Self::Month | Self::MonthName | Self::Day) + } + + /// Read from a run of digits, not from letters. + fn is_numeric(self) -> bool { + !matches!(self, Self::MonthName | Self::Weekday | Self::AmPm) + } +} + +/// A piece of a `util.scand` format. +#[derive(Debug, PartialEq, Eq)] +enum ScanPart { + /// A field, the digits it takes when typed together with the next one (`width`), and whether + /// the next one follows it with nothing between them in the format (`joined`). + Field { unit: DateUnit, width: usize, joined: bool }, + /// Letters the format writes as they are, which the text must have there. + Word(String), +} + +fn scan_parts(fmt: &str) -> Vec { + let chars: Vec = fmt.chars().collect(); + let mut parts: Vec = Vec::new(); + // The last part is a field written right before this point, or letters still being collected. + let (mut after_field, mut in_word) = (false, false); + let mut i = 0; + while let Some(&c) = chars.get(i) { + // Counted for field letters only, so a long run of anything else is read once. + let run = if "ymdHhMst".contains(c) { chars.get(i..).unwrap_or_default().iter().take_while(|x| **x == c).count() } else { 1 }; + let field = match (c, run) { + ('y', 4..) => Some((DateUnit::Year, 4)), + ('y', _) => Some((DateUnit::ShortYear, 2)), + ('m', 3..) => Some((DateUnit::MonthName, 0)), + ('m', n) => Some((DateUnit::Month, n)), + ('d', 3..) => Some((DateUnit::Weekday, 0)), + ('d', n) => Some((DateUnit::Day, n)), + ('H', n) => Some((DateUnit::Hour, n.min(2))), + ('h', n) => Some((DateUnit::Hour12, n.min(2))), + ('M', n) => Some((DateUnit::Minute, n.min(2))), + ('s', n) => Some((DateUnit::Second, n.min(2))), + ('t', _) => Some((DateUnit::AmPm, 0)), + _ => None, + }; + if let Some((unit, width)) = field { + if after_field + && unit.is_numeric() + && let Some(ScanPart::Field { unit: last, joined, .. }) = parts.last_mut() + && last.is_numeric() + { + *joined = true; + } + parts.push(ScanPart::Field { unit, width, joined: false }); + (after_field, in_word) = (true, false); + i += run; + continue; + } + // `\` writes the next character as it is. + let (literal, step) = if c == '\\' { (chars.get(i + 1).copied(), 2) } else { (Some(c), 1) }; + i += step; + after_field = false; + match literal { + Some(l) if l.is_alphanumeric() => match parts.last_mut() { + Some(ScanPart::Word(w)) if in_word => w.push(l), + _ => { + parts.push(ScanPart::Word(l.to_string())); + in_word = true; + } + }, + _ => in_word = false, + } + } + parts +} + +/// A run of digits or of letters in the text `util.scand` reads. +enum ScanToken<'a> { + Digits(&'a str), + Letters(&'a str), +} + +/// The text's runs of digits and of letters, in order; anything else only separates them, and +/// may not begin or end the text. `None` when it does. +fn scan_tokens(text: &str) -> Option>> { + // Some(true) for a digit, Some(false) for a letter, None for a separator. + let kind = |c: char| (c.is_ascii_digit() || c.is_alphabetic()).then_some(c.is_ascii_digit()); + kind(text.chars().next()?)?; + kind(text.chars().next_back()?)?; + let mut out = Vec::new(); + let mut start: Option<(usize, bool)> = None; + for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { + let k = kind(c); + if let Some((from, digits)) = start + && k != Some(digits) + { + let piece = text.get(from..at)?; + out.push(if digits { ScanToken::Digits(piece) } else { ScanToken::Letters(piece) }); + start = None; + } + if let Some(digits) = k + && start.is_none() + { + start = Some((at, digits)); + } + } + Some(out) +} + +/// What `util.scand` reads. +#[derive(Debug, PartialEq, Eq)] +enum Scanned { + /// The text is empty: today, at noon. + Today, + /// A date and time: year, month (1–12), day, hours, minutes, seconds. + At(i32, u32, u32, u32, u32, u32), +} + +/// The fields `util.scand` has read so far. +#[derive(Default)] +struct ScanValues { + y: Option, + mo: Option, + d: Option, + h: Option, + mi: Option, + sec: Option, + pm: Option, + /// The hour was read from `h` or `hh`. + twelve: bool, +} + +impl ScanValues { + fn set(&mut self, unit: DateUnit, v: u32) { + match unit { + DateUnit::Year => self.y = i32::try_from(v).ok(), + DateUnit::ShortYear => self.y = i32::try_from(v).ok().map(|v| if v < 50 { 2000 + v } else { 1900 + v }), + DateUnit::Month => self.mo = Some(v), + DateUnit::Day => self.d = Some(v), + DateUnit::Hour => self.h = Some(v), + DateUnit::Hour12 => { + self.h = Some(v); + self.twelve = true; + } + DateUnit::Minute => self.mi = Some(v), + DateUnit::Second => self.sec = Some(v), + DateUnit::MonthName | DateUnit::Weekday | DateUnit::AmPm => {} + } + } +} + +/// Reads `text` by a `util.scand` format, as another viewer was observed to (black-box): +/// +/// - Fields may be separated by anything but digits and letters, whether or not the format +/// separates them, but nothing may come before the first or after the last. +/// - Digits typed together fill the format's fields written together with exactly as many +/// digits as each has letters (`yyyymd`: 4, 1 and 1). A run of digits for one field alone may +/// have one or two, except a four-digit year. +/// - `yy` is 1950–2049; month names may be written in full or as three letters, in any case. +/// - Impossible dates are refused, but a month or day of 0 is read as 1. +/// - A format with no year, month or day reads nothing; an empty text is today, at noon. +/// +/// Not observed: a year left out is `this_year`, and a month or day left out is 1. +fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { + let parts = scan_parts(fmt); + if !parts.iter().any(|p| matches!(p, ScanPart::Field { unit, .. } if unit.is_date())) { + return None; + } + if text.is_empty() { + return Some(Scanned::Today); + } + let tokens = scan_tokens(text)?; + let mut v = ScanValues::default(); + let (mut p, mut t) = (0, 0); + while let Some(part) = parts.get(p) { + let token = tokens.get(t)?; + t += 1; + match (part, token) { + (ScanPart::Word(w), ScanToken::Letters(l) | ScanToken::Digits(l)) => { + if w.to_lowercase() != l.to_lowercase() { + return None; + } + p += 1; + } + (ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => { + v.mo = Some(named(&MONTHS, l)?.saturating_add(1)); + p += 1; + } + (ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => { + named(&DAYS, l)?; + p += 1; + } + (ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => { + v.pm = Some(match l.to_lowercase().as_str() { + "am" | "a" => false, + "pm" | "p" => true, + _ => return None, + }); + p += 1; + } + (ScanPart::Field { unit, width, joined }, ScanToken::Digits(run)) if unit.is_numeric() => { + // The fields written together from here, no more than the run's digits can fill, + // and the fewest of them the run fills. + let mut group = vec![(*unit, *width)]; + let (mut more, mut widths) = (*joined, *width); + while more + && widths < run.len() + && let Some(ScanPart::Field { unit, width, joined }) = parts.get(p + group.len()) + { + group.push((*unit, *width)); + widths = widths.saturating_add(*width); + more = *joined; + } + let mut sum = 0; + let fills = group.iter().position(|(_, w)| { + sum += w; + sum == run.len() + }); + if let Some(last) = fills { + let mut at = 0; + for (unit, w) in group.iter().take(last + 1) { + v.set(*unit, run.get(at..at + w)?.parse().ok()?); + at += w; + } + p += last + 1; + } else if *unit != DateUnit::Year && run.len() <= 2 { + v.set(*unit, run.parse().ok()?); + p += 1; + } else { + return None; + } + } + _ => return None, + } + } + if t != tokens.len() { + return None; + } + let y = v.y.unwrap_or(this_year); + let mo = v.mo.unwrap_or(1).max(1); + let d = v.d.unwrap_or(1).max(1); + if mo > 12 || i64::from(d) > i64::from(formcalc::days_in(i64::from(y), mo)) { + return None; + } + let mut h = v.h.unwrap_or(0); + if v.twelve && h > 12 { + return None; + } + match v.pm { + Some(true) if h < 12 => h += 12, + Some(false) if h == 12 => h = 0, + _ => {} + } + let (mi, sec) = (v.mi.unwrap_or(0), v.sec.unwrap_or(0)); + (h <= 23 && mi <= 59 && sec <= 59).then_some(Scanned::At(y, mo, d, h, mi, sec)) +} + +/// The index of the name `word` is, in full or as its first three letters, in any case. +fn named(names: &[&str], word: &str) -> Option { + let word = word.to_lowercase(); + let i = names.iter().position(|n| { + let n = n.to_lowercase(); + word == n || n.get(..3) == Some(word.as_str()) + })?; + u32::try_from(i).ok() +} + +/// `util.scand(cFormat, cDate)`: the date `cDate` gives by a `util.printd` format, as a local +/// `Date`, or `null` when it doesn't read as one (see [`scan_date`]). Only text is read. +fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let Some(typed) = arg(args, 1).as_string().map(|t| t.to_std_string_escaped()) else { + return Ok(JsValue::null()); + }; + let fmt = text(&arg(args, 0), ctx)?; + let date = JsDate::new(ctx); + let this_year = date.get_full_year(ctx)?.to_number(ctx)?; + let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 }; + match scan_date(&fmt, &typed, this_year) { + None => return Ok(JsValue::null()), + Some(Scanned::Today) => { + date.set_hours(&[12.into(), 0.into(), 0.into(), 0.into()], ctx)?; + } + Some(Scanned::At(y, mo, d, h, mi, sec)) => { + // setFullYear, not the constructor, so years 0–99 stay as they are. + date.set_full_year(&[y.into(), (mo.saturating_sub(1)).into(), d.into()], ctx)?; + date.set_hours(&[h.into(), mi.into(), sec.into(), 0.into()], ctx)?; + } + } + Ok(date.into()) +} + /// `util.printx(cFormat, cSource)`: `?` any character, `X` letter or digit, `A` letter, `9` /// digit, `*` the rest, `\` escape, `>` / `<` / `=` upper / lower / as is. fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { @@ -947,6 +1259,7 @@ fn install(ctx: &mut Context) -> JsResult<()> { .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) .build(); ctx.register_global_property(js_string!("util"), util, rw)?; let console = ObjectInitializer::new(ctx) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 61d3a71e51..791da93a24 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1132,3 +1132,182 @@ mod session { assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}"); } } + +mod scand { + use super::*; + use crate::xfa::{XfaDoc, XfaEvent, run_xfa}; + + /// Runs `util.scand(format, text)` for each case and checks the date it gives, written as + /// `yyyy-mm-dd HH:MM:ss`, or `null`. + fn assert_scand(cases: &[(&str, &str, &str)]) { + let list: Vec = cases.iter().map(|(f, t, _)| format!("['{f}', '{t}']")).collect(); + let script = format!( + "event.value = [{}].map(function (c) {{ var d = util.scand(c[0], c[1]); \ + return d === null ? 'null' : util.printd('yyyy-mm-dd HH:MM:ss', d); }}).join('|');", + list.join(", ") + ); + let o = go(&script, &Event::field("Calculate", "total", "")); + assert_eq!(o.error, None); + let got: Vec<&str> = o.value.split('|').collect(); + assert_eq!(got.len(), cases.len()); + for ((f, t, want), got) in cases.iter().zip(got) { + assert_eq!(got, *want, "util.scand('{f}', '{t}')"); + } + } + + // The cases below are another viewer's answers, observed black-box with a synthetic file. + + #[test] + fn scand_allows_separators_between_fields_but_nothing_before_or_after_them() { + assert_scand(&[ + ("yyyymmdd", "20261018", "2026-10-18 00:00:00"), + ("yyyymmdd", "2026-10-18", "2026-10-18 00:00:00"), + ("yyyymmdd", "2026 10 18", "2026-10-18 00:00:00"), + ("mm/dd/yyyy", "10/18/2026", "2026-10-18 00:00:00"), + ("mm/dd/yyyy", "10-18-2026", "2026-10-18 00:00:00"), + ("yyyymmdd", " 20261018 ", "null"), + ("yyyymmdd", "20261018x", "null"), + ("yyyymmdd", "x20261018", "null"), + ]); + } + + #[test] + fn scand_splits_digits_typed_together_by_the_formats_letter_counts() { + assert_scand(&[ + ("yyyymmdd", "261018", "null"), + ("yyyymmdd", "2026118", "null"), + ("yyyymmdd", "202610181", "null"), + ("yymmdd", "20261018", "null"), + ("yyyymdd", "2026118", "2026-01-18 00:00:00"), + ("yyyymmd", "2026118", "2026-11-08 00:00:00"), + ("yyyymd", "2026118", "null"), + ("yyyymd", "202618", "2026-01-08 00:00:00"), + ("yyyymd", "20261018", "null"), + ("yymd", "2618", "2026-01-08 00:00:00"), + ("yymd", "261018", "null"), + ]); + } + + #[test] + fn scand_refuses_impossible_dates_and_reads_a_zero_month_or_day_as_1() { + assert_scand(&[ + ("yyyymmdd", "20261318", "null"), + ("yyyymmdd", "20260230", "null"), + ("yyyymmdd", "20260229", "null"), + ("yyyymmdd", "20240229", "2024-02-29 00:00:00"), + ("mm/dd/yyyy", "13/45/2026", "null"), + ("yyyymmdd", "20260018", "2026-01-18 00:00:00"), + ("yyyymmdd", "20261000", "2026-10-01 00:00:00"), + ]); + } + + #[test] + fn scand_reads_lone_fields_two_digit_years_and_month_and_weekday_names() { + assert_scand(&[ + ("mm/dd/yyyy", "1/5/2026", "2026-01-05 00:00:00"), + ("m/d/yyyy", "1/5/2026", "2026-01-05 00:00:00"), + ("m/d/yyyy", "01/05/2026", "2026-01-05 00:00:00"), + ("m/d/yy", "1/5/26", "2026-01-05 00:00:00"), + ("mm/dd/yyyy", "10/18/26", "null"), + ("yyyy/mm/dd", "26/10/18", "null"), + ("yymmdd", "261018", "2026-10-18 00:00:00"), + ("yymmdd", "491018", "2049-10-18 00:00:00"), + ("yymmdd", "501018", "1950-10-18 00:00:00"), + ("mmm d, yyyy", "Oct 18, 2026", "2026-10-18 00:00:00"), + ("mmm d, yyyy", "October 18, 2026", "2026-10-18 00:00:00"), + ("mmm d, yyyy", "oct 18, 2026", "2026-10-18 00:00:00"), + ("mmmm d, yyyy", "Oct 18, 2026", "2026-10-18 00:00:00"), + ("mmmm d, yyyy", "October 18, 2026", "2026-10-18 00:00:00"), + ("dd-mmm-yyyy", "18-Oct-2026", "2026-10-18 00:00:00"), + ("dddd, mmmm d, yyyy", "Sunday, October 18, 2026", "2026-10-18 00:00:00"), + ]); + } + + #[test] + fn scand_reads_a_time_only_with_a_date() { + assert_scand(&[ + ("mm/dd/yyyy HH:MM:ss", "10/18/2026 21:07:05", "2026-10-18 21:07:05"), + ("HH:MM", "09:07", "null"), + ("HH:MM", "9:07", "null"), + ("h:MM tt", "9:07 pm", "null"), + ("h:MM tt", "9:07 am", "null"), + ("h:MM tt", "12:07 am", "null"), + ("h:MM tt", "9:07pm", "null"), + ]); + } + + #[test] + fn scand_reads_only_text_and_an_empty_text_is_today_at_noon() { + let o = go( + "var d = util.scand('yyyymmdd', ''), n = new Date(); \ + event.value = [d.toDateString() == n.toDateString(), d.getHours(), d.getMinutes(), d.getSeconds(), \ + util.scand('yyyymmdd', 20261018), util.scand(), util.scand('yyyymmdd')].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|12|0|0|||"); + } + + // Not observed: the choices made where the other viewer wasn't asked. + + #[test] + fn scand_takes_this_year_when_none_is_given_and_reads_12_hour_times_and_written_letters() { + assert_scand(&[ + ("mm/dd/yyyy h:MM tt", "10/18/2026 9:07 pm", "2026-10-18 21:07:00"), + ("mm/dd/yyyy h:MM tt", "10/18/2026 12:07 AM", "2026-10-18 00:07:00"), + ("mm/dd/yyyy h:MM tt", "10/18/2026 13:07 pm", "null"), + ("mm/dd/yyyy HH:MM", "10/18/2026 24:00", "null"), + ("yyyy-mm-ddTHH:MM", "2026-10-18T21:07", "2026-10-18 21:07:00"), + ("yyyy-mm-ddTHH:MM", "2026-10-18X21:07", "null"), + ]); + let o = go( + "var d = util.scand('mm/dd', '10/18'); event.value = [d.getFullYear() == new Date().getFullYear(), d.getMonth(), d.getDate()].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|9|18"); + } + + #[test] + fn scand_reads_long_formats_and_texts_in_linear_time() { + // Scripts can pass anything; native code isn't bounded by the loop limit. + let started = std::time::Instant::now(); + let o = go( + "var x = new Array(50001).join('x'), md = new Array(50001).join('md'), ones = new Array(100000).join('1 ') + '1'; \ + event.value = [util.scand(x + 'yyyy', x + '2026') !== null, util.scand('yyyy' + md, '2026 ' + ones) !== null].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|true"); + assert!(started.elapsed() < std::time::Duration::from_secs(2), "{:?}", started.elapsed()); + } + + #[test] + fn xfa_scripts_check_typed_dates_with_util_scand() { + // The check a form runs when a date field is left: the first of six formats that reads + // the typed text, written back as yyyymmdd; or an invalid date. Results as another viewer gives them. + let check = |typed: &str| { + let script = format!( + "var six = ['yyyymmdd', 'yymmdd', 'yyyymdd', 'yyyymmd', 'yyyymd', 'yymd'], out = 'invalid'; \ + for (var i = 0; i < six.length; i++) {{ var d = util.scand(six[i], '{typed}'); \ + if (d !== null) {{ out = util.printd('yyyymmdd', d); break; }} }} out" + ); + let event = XfaEvent { activity: "exit".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + run_xfa(&script, &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default()) + }; + for (typed, want) in [ + ("20261018", "20261018"), + ("261018", "20261018"), + ("2026118", "20260118"), + ("202618", "20260108"), + ("2618", "20260108"), + ("2026-10-18", "20261018"), + ("20261318", "invalid"), + ("20260230", "invalid"), + ] { + let o = check(typed); + assert_eq!(o.error, None, "{typed}: {o:?}"); + assert_eq!(o.result.as_deref(), Some(want), "{typed}"); + } + } +} diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index a2361da7e4..9a48ff16ab 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -15,7 +15,7 @@ use boa_engine::object::builtins::{JsArray, JsProxy}; use boa_engine::property::Attribute; use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; -use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, text}; +use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; /// What kind of template object a node is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1208,6 +1208,7 @@ fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) .build(); ctx.register_global_property(js_string!("util"), util, rw)?; // Unqualified names: the current node's children, its siblings, then each ancestor's diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8b..b8c0c9f368 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5136,8 +5136,8 @@ milestone = "M6" status = "partial" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet." +evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"] +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; no annotations, layers or dialogs (app.response returns null) yet." [[feature]] id = "form.js-console" From 3a5bcbdf97663569fa4bf0e80202d7e1ecf101c2 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:21:08 -0400 Subject: [PATCH 029/517] M12: a date typed by another of an XFA field's edit patterns is kept in its first --- crates/engine/src/lib.rs | 18 +++++++++++---- crates/engine/src/tests.rs | 20 ++++++++++++++++ crates/engine/src/xfa.rs | 22 ++++++++++++++++++ crates/xfa/src/fixtures.rs | 18 +++++++++++++++ crates/xfa/src/layout.rs | 2 +- crates/xfa/src/lib.rs | 2 ++ crates/xfa/src/picture.rs | 47 ++++++++++++++++++++++++++++++++++++++ crates/xfa/src/tests.rs | 15 ++++++++++++ 8 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 crates/xfa/src/picture.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dfe11b25c6..3b21c952e1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2428,10 +2428,20 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::SetAnnotationInfo { page, index, author, subject, icon } => { pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; } - Edit::SetFieldValue { name, value } => match cx.js.as_mut() { - Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js)?, - None => pdfcraft_forms::set_value(doc, name, value)?, - }, + Edit::SetFieldValue { name, value } => { + let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { + Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), + None => pdfcraft_forms::set_value(doc, name, value), + }; + if let Err(refused) = set(doc, value) { + // A date the field's pattern doesn't read may be typed by another of an XFA date + // field's patterns: it is kept in the first. + match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { + Some(typed) => set(doc, &typed)?, + None => return Err(refused.into()), + } + } + } Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9365e744cb..8d4745add9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6153,3 +6153,23 @@ 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"); } + +#[test] +fn a_date_typed_by_another_of_an_xfa_fields_edit_patterns_is_kept_in_its_first() { + let mut s = Session::new(); + let id = s + .open("dates.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::date_alternatives_template())), None) + .expect("opens"); + let value = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "leave").unwrap().value.clone(); + s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("261018".into()) }).expect("yymmdd is one of its patterns"); + assert_eq!(value(&s), ["20261018"]); + s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("2026118".into()) }).expect("so is yyyymd"); + assert_eq!(value(&s), ["20261108"]); + let refused = s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("2618".into()) }); + assert!(refused.is_err_and(|e| e.to_string().contains("yyyymmdd")), "none of its patterns reads it"); + assert_eq!(value(&s), ["20261108"]); + // The data holds the date. + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).expect("a datasets packet"); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].leave[0]")), Some("2026-11-08")); +} diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 0f37933afd..8f8e0a3871 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -810,6 +810,28 @@ pub(crate) fn on_data_replaced(doc: &mut pdfcraft_cos::Document, tpl: &Template, r.finish().map(|_| ()) } +/// A text value typed into the laid-out XFA date field `name` by another of its edit picture's +/// patterns, written in the first ([`pdfcraft_xfa::typed_date`]); `None` for anything else. +/// `tpl` is the template the scripts use, when they run; otherwise it is read from `doc`. +pub(crate) fn typed_date( + doc: &pdfcraft_cos::Document, + tpl: Option<&Template>, + name: &str, + value: &pdfcraft_forms::FieldValue, +) -> Option { + let pdfcraft_forms::FieldValue::Text(text) = value else { return None }; + let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find_map(|(som, n)| (n == name).then_some(som))?; + let read; + let tpl = match tpl { + Some(t) => t, + None => { + read = pdfcraft_xfa::template_of(doc).ok()?; + &read + } + }; + pdfcraft_xfa::typed_date(tpl, &som, text).map(pdfcraft_forms::FieldValue::Text) +} + /// A button's click script (or any event, by SOM path and activity), then calculations. pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<(), String> { let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS); diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 33dbb017e5..b7ae134820 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -375,6 +375,24 @@ pub fn fields_template(data: &str) -> String { ) } +/// Date fields whose edit pictures list alternatives, as forms let a date be typed several ways +/// (`date{YYYYMMDD}|date{YYMMDD}|…`); one with a single pattern; a text field. +pub fn date_alternatives_template() -> String { + r##" + + + +"## + .to_string() +} + /// A form of `sections` sections of `rows` rows, each row with a calculated total (half in /// JavaScript, half in FormCalc) and a section sum over its rows: 2 × sections × rows + sections /// calculate scripts, every one reading values the earlier ones settled. diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index b7c2605a6e..f6f4ab18d7 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -395,7 +395,7 @@ fn is_row(node: &Node) -> bool { } /// The Acrobat date pattern for an XFA picture clause such as `date{YYYY-MM-DD}`. -fn date_pattern(picture: Option<&str>) -> String { +pub(crate) fn date_pattern(picture: Option<&str>) -> String { picture.and_then(date_letters).unwrap_or_else(|| "yyyy-mm-dd".into()) } diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index bf4414970e..d6f4837be3 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -15,6 +15,7 @@ pub mod model; pub mod packets; pub mod parse; pub mod pdf; +pub mod picture; pub mod script; pub mod text; @@ -99,6 +100,7 @@ pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, Widge pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets}; pub use parse::{measure, parse}; pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape}; +pub use picture::typed_date; pub use script::{ FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, fields_by_som, form_tree, has_scripts, overrides, refresh_value, rerender, set_overrides, template_of, diff --git a/crates/xfa/src/picture.rs b/crates/xfa/src/picture.rs new file mode 100644 index 0000000000..58bd48cd69 --- /dev/null +++ b/crates/xfa/src/picture.rs @@ -0,0 +1,47 @@ +//! Picture clauses that list alternatives (`date{YYYYMMDD}|date{YYMMDD}`): a date may be typed +//! by any of them, and is shown, and kept in the field, by the first. + +use crate::data::{date_letters, iso_to_pattern, pattern_to_iso, template_field}; +use crate::layout::date_pattern; +use crate::model::{Template, Ui}; + +/// The alternatives of a picture clause: its parts between `|`s that aren't inside braces or +/// quotes. +pub(crate) fn alternatives(picture: &str) -> Vec<&str> { + let mut out = Vec::new(); + let (mut depth, mut quoted, mut start) = (0usize, false, 0); + for (i, c) in picture.char_indices() { + match c { + '\'' => quoted = !quoted, + '{' if !quoted => depth = depth.saturating_add(1), + '}' if !quoted => depth = depth.saturating_sub(1), + '|' if !quoted && depth == 0 => { + out.extend(picture.get(start..i)); + start = i + 1; + } + _ => {} + } + } + out.extend(picture.get(start..)); + out +} + +/// `text` typed into the date field at `som` by one of its edit picture's alternatives after the +/// first, written in the first, which the field shows and checks typed dates against (`261018` +/// becomes `20261018` for `date{YYYYMMDD}|date{YYMMDD}`). A pattern reads the text only when the +/// date it gives, written in that pattern, is the text again. `None` when the field isn't a date +/// field with alternatives, the first pattern reads the text, or no other does. +pub fn typed_date(tpl: &Template, som: &str, text: &str) -> Option { + let f = template_field(tpl, som)?; + if f.ui != Ui::DateTimeEdit { + return None; + } + let picture = f.picture.as_deref()?; + let reads = |pattern: &str| pattern_to_iso(text, pattern).filter(|iso| iso_to_pattern(iso, pattern).eq_ignore_ascii_case(text.trim())); + let first = date_pattern(Some(picture)); + if reads(&first).is_some() { + return None; + } + let iso = alternatives(picture).into_iter().skip(1).filter_map(date_letters).find_map(|p| reads(&p))?; + Some(iso_to_pattern(&iso, &first)) +} diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 0a192479a7..bdf6b6fbd4 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2141,3 +2141,18 @@ fn som_indices_count_earlier_same_named_siblings() { let sibs = SomIndices::of(&children); assert_eq!((0..=children.len()).map(|i| sibs.at(i)).collect::>(), [0, 0, 1, 0, 2, 1, 0]); } + +#[test] +fn a_date_typed_by_another_of_its_edit_pictures_patterns_is_written_in_the_first() { + let (tpl, _) = crate::parse(&crate::fixtures::date_alternatives_template()).unwrap(); + let typed = |som: &str, text: &str| crate::typed_date(&tpl, som, text); + let leave = "form[0].leave[0]"; + assert_eq!(typed(leave, "261018").as_deref(), Some("20261018"), "by yymmdd"); + assert_eq!(typed(leave, "2026118").as_deref(), Some("20261108"), "by yyyymd"); + assert_eq!(typed(leave, "26 10 18").as_deref(), Some("20261018"), "by yy mm dd"); + assert_eq!(typed(leave, "20261018"), None, "the first pattern reads it: it stays as typed"); + assert_eq!(typed(leave, "not a date"), None); + assert_eq!(typed(leave, "2618"), None, "no pattern has room for four digits"); + assert_eq!(typed("form[0].born[0]", "181026"), None, "one pattern: nothing else to try"); + assert_eq!(typed("form[0].name[0]", "261018"), None, "not a date field"); +} From e8eb840ec53d8fa1e1ac070b2e3b67c86bc21ecf Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:59:14 -0400 Subject: [PATCH 030/517] =?UTF-8?q?M6:=20form=20buttons=20show=20the=20sid?= =?UTF-8?q?e=20panels=20(execMenuItem=20ShowHide=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/automation/src/a11y.rs | 1 + crates/engine/src/js.rs | 11 ++++++++-- crates/engine/src/tests.rs | 27 ++++++++++++++++++++++++ crates/engine/src/xfa.rs | 2 ++ crates/js/src/lib.rs | 39 ++++++++++++++++++++++++++++++++++- crates/js/src/tests.rs | 36 ++++++++++++++++++++++++++++++++ crates/js/src/xfa.rs | 9 +++++--- crates/ui-egui/src/js_ui.rs | 13 +++++++++++- crates/ui-egui/tests/forms.rs | 15 ++++++++++++++ parity/acrobat-features.toml | 2 +- 10 files changed, 147 insertions(+), 8 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 2124549731..3a1422ec78 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -657,6 +657,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::Focus(f) => json!({ "focus": f }), R::Beep => json!({ "beep": true }), R::SaveAs => json!({ "save_as": true }), + R::ShowPanel(p) => json!({ "panel": format!("{p:?}").to_lowercase() }), } } diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index b797bc8d83..82ee90c5db 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -7,7 +7,7 @@ //! off, scripts don't run and AF calls keep working natively. use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts}; -pub use pdfcraft_js::{Limits, Outcome, Request}; +pub use pdfcraft_js::{Limits, Outcome, Panel, Request}; use crate::{DocId, Edit, EditError, Session}; @@ -29,7 +29,14 @@ impl JsOutput { fn absorb(&mut self, o: &Outcome) { self.alerts.extend(o.alerts.iter().cloned()); self.console.extend(o.console.iter().cloned()); - self.requests.extend(o.requests.iter().cloned()); + // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a + // button's may show or hide a side panel. + for r in &o.requests { + match r { + Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()), + r => self.requests.push(r.clone()), + } + } self.errors.extend(o.error.iter().cloned()); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41dac..72854e37ac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6106,3 +6106,30 @@ 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"); } + +#[test] +fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("files.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + s.take_js_output(id); + s.apply(id, Edit::XfaEvent { som: "form[0].files[0]".into(), activity: "click".into() }).unwrap(); + let out = s.take_js_output(id); + assert_eq!(out.requests, [pdfcraft_js::Request::ShowPanel(pdfcraft_js::Panel::Attachments)], "{out:?}"); + s.apply(id, Edit::SetFieldValue { name: "note".into(), value: FieldValue::Text("x".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "only a click shows a panel: {out:?}"); + assert!(out.console.iter().any(|c| c.contains("show a side panel")), "{:?}", out.console); +} diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 0f37933afd..313bfd1caf 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -53,6 +53,7 @@ fn click_only(e: &XfaEffect) -> Option<&'static str> { XfaEffect::Print => Some("print"), XfaEffect::SaveAs => Some("save"), XfaEffect::LaunchUrl(_) => Some("open a link"), + XfaEffect::ShowPanel(_) => Some("show a side panel"), XfaEffect::SetFocus(_) => Some("move the focus"), _ => None, } @@ -626,6 +627,7 @@ impl<'a, 'b> Runner<'a, 'b> { XfaEffect::Print => self.run.out.requests.push(Request::Print), XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs), XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())), + XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)), XfaEffect::SetFocus(som) => { if let Some(name) = self.by_som().get(som).cloned() { self.run.out.requests.push(Request::Focus(name)); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index d8d9fa8bac..987d28da49 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -13,7 +13,7 @@ //! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`, //! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, //! `setFocus`. -//! - `app` (`alert`, `beep`, `response`, `launchURL`, `viewerType`, `viewerVersion`, +//! - `app` (`alert`, `beep`, `response`, `launchURL`, `execMenuItem`, `viewerType`, `viewerVersion`, //! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`), `console` (`println`, //! `show`, `clear`), `display` and `color`. //! @@ -159,6 +159,19 @@ pub enum Request { Beep, /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). SaveAs, + /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. + ShowPanel(Panel), +} + +/// A side panel a script can show or hide (`app.execMenuItem("ShowHideFileAttachment")`, …). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Panel { + Bookmarks, + /// Page thumbnails. + Pages, + Attachments, + Signatures, + Layers, } /// What running a script did. @@ -565,6 +578,29 @@ fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Option { + Some(match name { + "ShowHideFileAttachment" => Request::ShowPanel(Panel::Attachments), + "ShowHideBookmarks" => Request::ShowPanel(Panel::Bookmarks), + "ShowHideThumbnails" => Request::ShowPanel(Panel::Pages), + "ShowHideSignatures" => Request::ShowPanel(Panel::Signatures), + "ShowHideOptCont" => Request::ShowPanel(Panel::Layers), + "Print" => Request::Print, + "SaveAs" | "Save" => Request::SaveAs, + _ => return None, + }) +} + +fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let item = text(&arg(args, 0), ctx)?; + if let Some(r) = menu_item(&item) { + host(ctx)?.borrow_mut().requests.push(r); + } + Ok(JsValue::undefined()) +} + fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { // No one to answer: as if the dialog was cancelled. Ok(JsValue::null()) @@ -931,6 +967,7 @@ fn install(ctx: &mut Context) -> JsResult<()> { .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0) .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) + .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2) .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2) .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 61d3a71e51..bfb53996fc 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1132,3 +1132,39 @@ mod session { assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}"); } } + +mod menu_items { + use super::*; + use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa}; + + #[test] + fn exec_menu_item_shows_side_panels_prints_and_saves() { + let o = go( + "app.execMenuItem('ShowHideFileAttachment'); app.execMenuItem('ShowHideBookmarks'); app.execMenuItem('ShowHideThumbnails'); \ + app.execMenuItem('ShowHideSignatures'); app.execMenuItem('ShowHideOptCont'); app.execMenuItem('Print'); \ + app.execMenuItem('SaveAs'); app.execMenuItem('NoSuchItem');", + &Event::field("Mouse Up", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!( + o.requests, + [ + Request::ShowPanel(Panel::Attachments), + Request::ShowPanel(Panel::Bookmarks), + Request::ShowPanel(Panel::Pages), + Request::ShowPanel(Panel::Signatures), + Request::ShowPanel(Panel::Layers), + Request::Print, + Request::SaveAs, + ] + ); + } + + #[test] + fn xfa_scripts_show_the_attachments_panel() { + let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() }; + let o = run_xfa("app.execMenuItem('ShowHideFileAttachment')", &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default()); + assert_eq!(o.error, None, "{o:?}"); + assert!(o.effects.contains(&XfaEffect::ShowPanel(Panel::Attachments)), "{:?}", o.effects); + } +} diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index a2361da7e4..8f64f9049c 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -112,6 +112,8 @@ pub enum XfaEffect { MessageBox(String), /// `xfa.host.resetData()`: the named fields, or every field when empty. ResetData(Vec), + /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. + ShowPanel(crate::Panel), Print, SaveAs, LaunchUrl(String), @@ -1049,9 +1051,10 @@ fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { let item = text(&arg(args, 0), ctx)?; - match item.as_str() { - "SaveAs" | "Save" => effect(ctx, XfaEffect::SaveAs)?, - "Print" => effect(ctx, XfaEffect::Print)?, + match crate::menu_item(&item) { + Some(crate::Request::SaveAs) => effect(ctx, XfaEffect::SaveAs)?, + Some(crate::Request::Print) => effect(ctx, XfaEffect::Print)?, + Some(crate::Request::ShowPanel(p)) => effect(ctx, XfaEffect::ShowPanel(p))?, _ => {} } Ok(JsValue::undefined()) diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index df461fc6e7..98b2afbd84 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -2,7 +2,7 @@ //! the JavaScript console (⌘J), Document JavaScripts, and Preferences ▸ JavaScript. use egui::{Align, Layout}; -use pdfcraft_engine::js::{JsOutput, Request}; +use pdfcraft_engine::js::{JsOutput, Panel, Request}; use pdfcraft_engine::{DocId, Edit}; use crate::theme::{self, Tokens}; @@ -67,6 +67,17 @@ impl PdfCraftApp { &[("u", &u)], ), Request::SaveAs => self.run_command("file.save_as"), + Request::ShowPanel(p) => { + let panel = match p { + Panel::Bookmarks => crate::RightPanel::Bookmarks, + Panel::Pages => crate::RightPanel::Pages, + Panel::Attachments => crate::RightPanel::Attachments, + Panel::Signatures => crate::RightPanel::Signatures, + Panel::Layers => crate::RightPanel::Layers, + }; + // Show/Hide: a panel already showing closes. + self.right = (self.right != Some(panel)).then_some(panel); + } Request::Focus(_) | Request::Beep | Request::Reset(_) => {} } } diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index e153dcca94..6b2add4866 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1072,3 +1072,18 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() { // An explicit size is kept, as in Acrobat: the text may then run past the box. assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0); } + +#[test] +fn a_form_button_shows_and_hides_the_attachments_panel() { + let mut h = harness(); + h.state_mut().set_option("panel", "none").unwrap(); + h.run_steps(2); + let id = h.state().views[0].id; + h.state_mut().run_button_script(id, "size", "app.execMenuItem('ShowHideFileAttachment');"); + h.run_steps(2); + assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Attachments)); + // Show/Hide, as its name says: asked again, it closes. + h.state_mut().run_button_script(id, "size", "app.execMenuItem('ShowHideFileAttachment');"); + h.run_steps(2); + assert_eq!(h.state().right, None); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8b..f47140442c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5137,7 +5137,7 @@ status = "partial" commands = ["tools.js_console"] tools = ["js_run"] evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet." +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels, Print and Save As, from a button only), util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet." [[feature]] id = "form.js-console" From 74326b455c1073b3a3bd69d08f7900a0a4f329b7 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:02:37 -0400 Subject: [PATCH 031/517] M6: a field's own script may not open Save As --- crates/engine/src/js.rs | 5 ++++- crates/engine/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 82ee90c5db..73be056600 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -30,10 +30,13 @@ impl JsOutput { self.alerts.extend(o.alerts.iter().cloned()); self.console.extend(o.console.iter().cloned()); // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a - // button's may show or hide a side panel. + // button's may show or hide a side panel, or open Save As. for r in &o.requests { match r { Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()), + Request::SaveAs => { + self.console.push("A field script asked to save the document as a new file; only a button may open Save As".into()) + } r => self.requests.push(r.clone()), } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 72854e37ac..4c19b80119 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6133,3 +6133,26 @@ fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() { assert!(out.requests.is_empty(), "only a click shows a panel: {out:?}"); assert!(out.console.iter().any(|c| c.contains("show a side panel")), "{:?}", out.console); } + +#[test] +fn a_fields_own_script_may_not_save_as_or_show_a_panel() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + s.apply( + id, + Edit::SetFieldScript { + name: "qty".into(), + event: "validate".into(), + script: Some("app.execMenuItem('SaveAs'); app.execMenuItem('ShowHideFileAttachment');".into()), + }, + ) + .unwrap(); + s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "a validate script runs unasked: {out:?}"); + assert_eq!(out.console.iter().filter(|c| c.contains("only a button may")).count(), 2, "{:?}", out.console); + // A button's script may. + let o = s.run_javascript(id, "app.execMenuItem('SaveAs');", Some("go")).unwrap(); + assert_eq!(o.requests, [js::Request::SaveAs]); +} From 39a9a6d2ca800b24caabd426efd0813b5570240b Mon Sep 17 00:00:00 2001 From: Osvaldo Duran Melendez Date: Mon, 19 Oct 2026 21:08:21 -0400 Subject: [PATCH 032/517] Attachments: report the added file's own key; drop an emptied indirect /Names attachment_add compared every attachment name, including files attached to pages as comments, so a page comment with the same file name made the reported key empty. It now compares only the document-level name-tree keys. Deleting the last document attachment when the catalog's /Names is an indirect dictionary left an empty /Names behind; it is now removed from the catalog, as the direct case already did. Co-Authored-By: Claude Sonnet 5.5 --- crates/automation/src/lib.rs | 17 ++++++- crates/automation/tests/attachment_key.rs | 34 +++++++++++++ crates/organize/src/attachments.rs | 7 +++ .../tests/attachment_names_cleanup.rs | 48 +++++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 crates/automation/tests/attachment_key.rs create mode 100644 crates/organize/tests/attachment_names_cleanup.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 8e4acdf891..1941c1858c 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -761,9 +761,10 @@ impl Automation { }; let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string); let description = a.opt_str("description")?.map(str::to_string); - let before: Vec = self.doc(&a)?.info.attachments.iter().map(|x| x.name.clone()).collect(); + // Only the document-level keys: an annotation attachment of the same name is not the new one. + let before = document_attachment_keys(self.doc(&a)?); let out = self.apply(&a, Edit::AddAttachment { name, bytes, description, mime })?; - let key = self.doc(&a)?.info.attachments.iter().map(|x| x.name.clone()).find(|n| !before.contains(n)).unwrap_or_default(); + let key = document_attachment_keys(self.doc(&a)?).into_iter().find(|n| !before.contains(n)).unwrap_or_default(); json!({ "name": key, "document": out }) } "attachment_delete" => { @@ -2314,6 +2315,18 @@ impl Args<'_> { /// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the /// next level down in `children`, and paths and pages are 1-based. +/// The name-tree keys of a document's own attachments, leaving out the files attached to pages as comments. +fn document_attachment_keys(doc: &Document) -> Vec { + doc.info + .attachments + .iter() + .filter_map(|x| match &x.source { + pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), + pdfcraft_render::AttachmentSource::Annotation { .. } => None, + }) + .collect() +} + fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec { // The open rows, outermost first, with their depth and the children gathered so far. let mut open: Vec<(usize, Value, Vec)> = Vec::new(); diff --git a/crates/automation/tests/attachment_key.rs b/crates/automation/tests/attachment_key.rs new file mode 100644 index 0000000000..25b0d6c602 --- /dev/null +++ b/crates/automation/tests/attachment_key.rs @@ -0,0 +1,34 @@ +//! `attachment_add` reports the name-tree key of the file it added, even when a file attached to +//! a page as a comment already has the same name. + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::{AttachIcon, Edit, NewAnnotation, Session, Shape, Style}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +#[test] +fn attachment_add_reports_its_own_key_when_a_page_comment_has_the_same_file_name() { + let dir = tempfile::tempdir().unwrap(); + let mut session = Session::new(); + let bytes = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open("a.pdf", None, bytes, None).unwrap(); + let shape = Shape::Attachment { at: [20.0, 300.0], icon: AttachIcon::Paperclip, file: "notes.txt".into(), data: b"on the page".to_vec() }; + session + .apply(id, Edit::AddAnnotation(NewAnnotation { page: 0, shape, style: Style::default(), contents: String::new(), author: "Ada".into() })) + .unwrap(); + std::fs::write(dir.path().join("a.pdf"), session.save_bytes(id).unwrap().as_slice()).unwrap(); + std::fs::write(dir.path().join("notes.txt"), b"for the document").unwrap(); + + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let doc = tool(&mut auto, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let added = tool(&mut auto, "attachment_add", json!({ "doc": doc, "path": "notes.txt" })); + assert_eq!(added["name"], "notes.txt", "{added}"); + let again = tool(&mut auto, "attachment_add", json!({ "doc": doc, "path": "notes.txt" })); + assert_eq!(again["name"], "notes.txt (2)", "{again}"); +} diff --git a/crates/organize/src/attachments.rs b/crates/organize/src/attachments.rs index d785f1a4db..3705ad9ed3 100644 --- a/crates/organize/src/attachments.rs +++ b/crates/organize/src/attachments.rs @@ -103,9 +103,16 @@ fn clear_embedded_files(doc: &mut Document) -> Result<(), OrganizeError> { let names = doc.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()); match names { Some(Object::Ref(r)) => { + let mut empty = false; doc.update_dict(r, |n| { n.remove(b"EmbeddedFiles"); + empty = n.is_empty(); })?; + if empty { + doc.update_dict(root, |c| { + c.remove(b"Names"); + })?; + } } Some(Object::Dict(mut n)) => { n.remove(b"EmbeddedFiles"); diff --git a/crates/organize/tests/attachment_names_cleanup.rs b/crates/organize/tests/attachment_names_cleanup.rs new file mode 100644 index 0000000000..3a5145e715 --- /dev/null +++ b/crates/organize/tests/attachment_names_cleanup.rs @@ -0,0 +1,48 @@ +//! Deleting the last document attachment when the catalog's `/Names` is an indirect dictionary +//! (as some producers write it) must not leave an empty `/Names` behind. + +use std::sync::Arc; + +use pdfcraft_cos::{Document, Object}; +use pdfcraft_organize::{add_attachment, attachment_keys, delete_attachment}; + +/// One page, and a catalog whose `/Names` is the indirect (empty) dictionary 4 0 R. +fn indirect_names_pdf() -> Vec { + let objs: [&[u8]; 4] = [ + b"<< /Type /Catalog /Pages 2 0 R /Names 4 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>", + b"<< >>", + ]; + 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 +} + +#[test] +fn deleting_the_last_attachment_removes_an_indirect_empty_names_dictionary() { + let mut doc = Document::open(Arc::new(indirect_names_pdf())).unwrap(); + let key = add_attachment(&mut doc, "notes.txt", b"hello", None, None, None).unwrap(); + assert_eq!(attachment_keys(&doc), ["notes.txt"]); + delete_attachment(&mut doc, &key).unwrap(); + assert!(attachment_keys(&doc).is_empty()); + let root = doc.root().unwrap(); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + assert!( + catalog.get(b"Names").is_none(), + "the catalog keeps no empty /Names: {:?}", + catalog.get(b"Names").map(|n| matches!(n, Object::Ref(_))) + ); +} From 61ccfa237b44100c22dd081ab35549e9866507e9 Mon Sep 17 00:00:00 2001 From: Osvaldo Duran Melendez Date: Mon, 19 Oct 2026 21:08:24 -0400 Subject: [PATCH 033/517] Attachments: keep the bookmark rows doc comment on its function Co-Authored-By: Claude Sonnet 5.5 --- crates/automation/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 1941c1858c..932a3e5688 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2313,8 +2313,6 @@ impl Args<'_> { } } -/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the -/// next level down in `children`, and paths and pages are 1-based. /// The name-tree keys of a document's own attachments, leaving out the files attached to pages as comments. fn document_attachment_keys(doc: &Document) -> Vec { doc.info @@ -2327,6 +2325,8 @@ fn document_attachment_keys(doc: &Document) -> Vec { .collect() } +/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the +/// next level down in `children`, and paths and pages are 1-based. fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec { // The open rows, outermost first, with their depth and the children gathered so far. let mut open: Vec<(usize, Value, Vec)> = Vec::new(); From 9366af3badb6ebfc096507b4add4e51779fbbd8a Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:01:10 -0400 Subject: [PATCH 034/517] M6: side panels have fixed automation ids, and the parity note says where Print works --- crates/automation/src/a11y.rs | 2 +- crates/js/src/lib.rs | 13 +++++++++++++ crates/js/src/tests.rs | 6 ++++++ parity/acrobat-features.toml | 2 +- 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 3a1422ec78..a9e13cbc34 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -657,7 +657,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::Focus(f) => json!({ "focus": f }), R::Beep => json!({ "beep": true }), R::SaveAs => json!({ "save_as": true }), - R::ShowPanel(p) => json!({ "panel": format!("{p:?}").to_lowercase() }), + R::ShowPanel(p) => json!({ "panel": p.id() }), } } diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 987d28da49..281898c4ad 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -174,6 +174,19 @@ pub enum Panel { Layers, } +impl Panel { + /// The panel's name in automation output, fixed whatever the variant is called. + pub fn id(self) -> &'static str { + match self { + Panel::Bookmarks => "bookmarks", + Panel::Pages => "pages", + Panel::Attachments => "attachments", + Panel::Signatures => "signatures", + Panel::Layers => "layers", + } + } +} + /// What running a script did. #[derive(Clone, Debug, Default, PartialEq)] pub struct Outcome { diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index bfb53996fc..ec4cfcc5eb 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1137,6 +1137,12 @@ mod menu_items { use super::*; use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa}; + #[test] + fn each_panel_has_a_fixed_id_for_automation() { + let ids = [Panel::Bookmarks, Panel::Pages, Panel::Attachments, Panel::Signatures, Panel::Layers].map(Panel::id); + assert_eq!(ids, ["bookmarks", "pages", "attachments", "signatures", "layers"]); + } + #[test] fn exec_menu_item_shows_side_panels_prints_and_saves() { let o = go( diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f47140442c..12e11d82ca 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5137,7 +5137,7 @@ status = "partial" commands = ["tools.js_console"] tools = ["js_run"] evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels, Print and Save As, from a button only), util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet." +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet." [[feature]] id = "form.js-console" From c90afe54cfdf08cd27ce2ca43e091d36f889c647 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:22:03 -0400 Subject: [PATCH 035/517] M6: say why a form's Save opens Save As --- crates/js/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 281898c4ad..aff27f951f 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -601,6 +601,8 @@ pub(crate) fn menu_item(name: &str) -> Option { "ShowHideSignatures" => Request::ShowPanel(Panel::Signatures), "ShowHideOptCont" => Request::ShowPanel(Panel::Layers), "Print" => Request::Print, + // Save opens Save As too, as it always has for XFA forms' buttons: a click on a form + // never overwrites the file unasked. "SaveAs" | "Save" => Request::SaveAs, _ => return None, }) From 032febca2fbcbe2884d9d99347e7b0103d2023ef Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:42:56 -0400 Subject: [PATCH 036/517] M6: doc comments for the functions this change touches --- crates/js/src/lib.rs | 9 +++++++++ crates/js/src/tests.rs | 17 +++++++++++++++++ crates/js/src/xfa.rs | 3 +++ 3 files changed, 29 insertions(+) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 0e7de9891f..8f3bb412d8 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -825,6 +825,7 @@ enum DateUnit { } impl DateUnit { + /// A year, month or day: a format needs one of these for `util.scand` to read anything. fn is_date(self) -> bool { matches!(self, Self::Year | Self::ShortYear | Self::Month | Self::MonthName | Self::Day) } @@ -845,6 +846,9 @@ enum ScanPart { Word(String), } +/// The fields and literal words of a `util.scand` format, in order. A numeric field is marked +/// `joined` when another numeric field follows it directly; anything but letters and digits +/// outside a field is left out. fn scan_parts(fmt: &str) -> Vec { let chars: Vec = fmt.chars().collect(); let mut parts: Vec = Vec::new(); @@ -956,6 +960,8 @@ struct ScanValues { } impl ScanValues { + /// Records `v` as the value of `unit`: a `yy` year as 1950–2049, and an `h` or `hh` hour + /// marked as 12-hour. Month names, weekdays and am/pm are not read here. fn set(&mut self, unit: DateUnit, v: u32) { match unit { DateUnit::Year => self.y = i32::try_from(v).ok(), @@ -1202,6 +1208,9 @@ fn color_object(ctx: &mut Context) -> JsObject { o.build() } +/// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, +/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, +/// `documentFileName`, `info`), and the `app`, `util`, `console`, `display` and `color` objects. fn install(ctx: &mut Context) -> JsResult<()> { let globals: [(&str, usize, NativeFunction); 9] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 791da93a24..c6b6d80d35 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1157,6 +1157,8 @@ mod scand { // The cases below are another viewer's answers, observed black-box with a synthetic file. + /// Any separator may come between fields, whether the format has that one, another or none, + /// but a text with a space or a letter before or after the date reads nothing. #[test] fn scand_allows_separators_between_fields_but_nothing_before_or_after_them() { assert_scand(&[ @@ -1171,6 +1173,8 @@ mod scand { ]); } + /// Digits typed together fill the joined fields with exactly as many digits as each field has + /// letters, so a run of any other length reads nothing. #[test] fn scand_splits_digits_typed_together_by_the_formats_letter_counts() { assert_scand(&[ @@ -1188,6 +1192,8 @@ mod scand { ]); } + /// A month past 12, a day past the month's end and February 29 outside a leap year are + /// refused, while a month or day of 0 is read as 1. #[test] fn scand_refuses_impossible_dates_and_reads_a_zero_month_or_day_as_1() { assert_scand(&[ @@ -1201,6 +1207,8 @@ mod scand { ]); } + /// A field typed by itself may have one or two digits, but a `yyyy` year needs four; `yy` reads + /// as 1950–2049; month names may be full or three letters in any case; weekday names are read. #[test] fn scand_reads_lone_fields_two_digit_years_and_month_and_weekday_names() { assert_scand(&[ @@ -1223,6 +1231,7 @@ mod scand { ]); } + /// A time is read along with a date, but a format with only time fields reads nothing. #[test] fn scand_reads_a_time_only_with_a_date() { assert_scand(&[ @@ -1236,6 +1245,8 @@ mod scand { ]); } + /// An empty text reads as today at noon, and a text that is not a string (a number, or none + /// at all) reads nothing. #[test] fn scand_reads_only_text_and_an_empty_text_is_today_at_noon() { let o = go( @@ -1250,6 +1261,8 @@ mod scand { // Not observed: the choices made where the other viewer wasn't asked. + /// A year left out is this year, `h:MM tt` reads a 12-hour time and refuses hours it cannot + /// have, and a letter the format writes must be typed there. #[test] fn scand_takes_this_year_when_none_is_given_and_reads_12_hour_times_and_written_letters() { assert_scand(&[ @@ -1268,6 +1281,8 @@ mod scand { assert_eq!(o.value, "true|9|18"); } + /// A format of 50,000 literal letters and one of 100,000 fields, with texts to match, are read + /// in under two seconds. #[test] fn scand_reads_long_formats_and_texts_in_linear_time() { // Scripts can pass anything; native code isn't bounded by the loop limit. @@ -1282,6 +1297,8 @@ mod scand { assert!(started.elapsed() < std::time::Duration::from_secs(2), "{:?}", started.elapsed()); } + /// The check an XFA form runs on leaving a date field tries six formats with `util.scand` and + /// writes the first that reads the text back as `yyyymmdd`, or reports an invalid date. #[test] fn xfa_scripts_check_typed_dates_with_util_scand() { // The check a form runs when a date field is left: the first of six formats that reads diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 9a48ff16ab..ef06e8418c 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -1098,6 +1098,9 @@ fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { resolver(ctx, current, many) } +/// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, +/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, +/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { let rw = Attribute::CONFIGURABLE; let form = node_object(ctx, 0)?; From a0ae8a39961de99120d7e027660c97f0f049d052 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:43:02 -0400 Subject: [PATCH 037/517] M6: doc comments for the functions this change touches --- crates/automation/src/a11y.rs | 2 ++ crates/engine/src/js.rs | 3 +++ crates/engine/src/tests.rs | 4 ++++ crates/js/src/lib.rs | 5 +++++ crates/js/src/tests.rs | 4 ++++ crates/js/src/xfa.rs | 2 ++ crates/ui-egui/tests/forms.rs | 2 ++ 7 files changed, 22 insertions(+) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index a9e13cbc34..19afb16a45 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -646,6 +646,8 @@ impl Automation { } } +/// A script's request to the viewer as the JSON that running a script reports; a side panel +/// goes by its fixed id. fn request_json(r: &pdfcraft_engine::js::Request) -> Value { use pdfcraft_engine::js::Request as R; match r { diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 73be056600..9b057900fc 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -26,6 +26,9 @@ impl JsOutput { self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty() } + /// Adds what a field's own script produced: its alerts, console lines, errors and requests, + /// except a request to show a side panel or open Save As, which is noted on the console + /// and dropped. fn absorb(&mut self, o: &Outcome) { self.alerts.extend(o.alerts.iter().cloned()); self.console.extend(o.console.iter().cloned()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4c19b80119..6b0a97e98e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6107,6 +6107,8 @@ fn undo_never_makes_an_unsaved_document_clean() { assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean"); } +/// An XFA button's click script may show the attachments panel; a text field's exit script +/// asking the same is noted on the console and ignored. #[test] fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() { let xdp = r##" @@ -6134,6 +6136,8 @@ fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() { assert!(out.console.iter().any(|c| c.contains("show a side panel")), "{:?}", out.console); } +/// A validate script that asks for Save As and a side panel gets neither, with a console note +/// for each; a button's script may still open Save As. #[test] fn a_fields_own_script_may_not_save_as_or_show_a_panel() { let mut s = Session::new().with_clock(|| 1_700_000_000); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index aff27f951f..1eb3d60e89 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -608,6 +608,8 @@ pub(crate) fn menu_item(name: &str) -> Option { }) } +/// `app.execMenuItem(name)`: queues the request [`menu_item`] gives for the name (Save opens +/// Save As too); an unknown name does nothing. fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let item = text(&arg(args, 0), ctx)?; if let Some(r) = menu_item(&item) { @@ -941,6 +943,9 @@ fn color_object(ctx: &mut Context) -> JsObject { o.build() } +/// Installs the form object model's globals: the document's functions and properties +/// (`getField`, `numFields`, `pageNum`, `info`, …), `app`, `util`, `console`, `display` and +/// `color`. fn install(ctx: &mut Context) -> JsResult<()> { let globals: [(&str, usize, NativeFunction); 9] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index ec4cfcc5eb..a78ce67de7 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1137,12 +1137,15 @@ mod menu_items { use super::*; use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa}; + /// Every side panel has a fixed id in automation output. #[test] fn each_panel_has_a_fixed_id_for_automation() { let ids = [Panel::Bookmarks, Panel::Pages, Panel::Attachments, Panel::Signatures, Panel::Layers].map(Panel::id); assert_eq!(ids, ["bookmarks", "pages", "attachments", "signatures", "layers"]); } + /// Each side panel item, Print and SaveAs make their request, in order; an unknown item + /// makes none and is not an error. #[test] fn exec_menu_item_shows_side_panels_prints_and_saves() { let o = go( @@ -1166,6 +1169,7 @@ mod menu_items { ); } + /// `app.execMenuItem` works in XFA scripts too: a click shows the attachments panel. #[test] fn xfa_scripts_show_the_attachments_panel() { let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() }; diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 8f64f9049c..093ba633b8 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -1049,6 +1049,8 @@ fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { let item = text(&arg(args, 0), ctx)?; match crate::menu_item(&item) { diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 6b2add4866..47a5c8b521 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1073,6 +1073,8 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() { assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0); } +/// A button script's `ShowHideFileAttachment` opens the attachments panel, and running it again +/// closes it. #[test] fn a_form_button_shows_and_hides_the_attachments_panel() { let mut h = harness(); From 83227d343a2c3486c7a545fd7bc55c1be3753802 Mon Sep 17 00:00:00 2001 From: Osvaldo Duran Melendez Date: Tue, 20 Oct 2026 00:22:45 -0400 Subject: [PATCH 038/517] Format with rustfmt Co-Authored-By: Claude Sonnet 5.5 --- crates/organize/tests/attachment_names_cleanup.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/organize/tests/attachment_names_cleanup.rs b/crates/organize/tests/attachment_names_cleanup.rs index 3a5145e715..b24a4611bc 100644 --- a/crates/organize/tests/attachment_names_cleanup.rs +++ b/crates/organize/tests/attachment_names_cleanup.rs @@ -40,9 +40,5 @@ fn deleting_the_last_attachment_removes_an_indirect_empty_names_dictionary() { assert!(attachment_keys(&doc).is_empty()); let root = doc.root().unwrap(); let catalog = doc.get(root).as_dict().cloned().unwrap(); - assert!( - catalog.get(b"Names").is_none(), - "the catalog keeps no empty /Names: {:?}", - catalog.get(b"Names").map(|n| matches!(n, Object::Ref(_))) - ); + assert!(catalog.get(b"Names").is_none(), "the catalog keeps no empty /Names: {:?}", catalog.get(b"Names").map(|n| matches!(n, Object::Ref(_)))); } From daa76197dc85898565adbb1084eaa3665cede3ee Mon Sep 17 00:00:00 2001 From: SBaksa Date: Tue, 20 Oct 2026 16:44:29 +1100 Subject: [PATCH 039/517] Select All takes the whole document's text while scrolling continuously (#2639) Scrolling continuously, Edit > Select All (Cmd+A) selects the text of every page of the document; one page at a time it still selects the page shown, as does a document of one page. Shown pages highlight as their text arrives, and Copy gets all of it in page order, reading the text of pages not read for display (text far away is let go to bound memory, so it isn't kept for this). A press on a page or Escape ends it. Select All still returns whether text is selected. The Select All parity entry covers both modes; the viewer and copy security tests run one page at a time, which is what they check. --- crates/ui-egui/src/canvas.rs | 141 +++++++++++++++++++++++++++++++- crates/ui-egui/src/commands.rs | 5 +- crates/ui-egui/src/lib.rs | 26 +++++- crates/ui-egui/tests/editing.rs | 5 ++ crates/ui-egui/tests/viewer.rs | 4 +- parity/acrobat-features.toml | 6 +- 6 files changed, 179 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb9244a284..b7abd2bb79 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -446,6 +446,9 @@ pub struct DocView { pub(crate) text_failed: HashSet, pub find: Option, selection: Option, + /// Select All while scrolling continuously (#2639): the text of every page of the document, + /// highlighted on each page as its text arrives and copied whole ([`DocView::whole_text`]). + pub(crate) all_text: bool, /// The selection whose action bar was closed by its Copy button: the bar stays hidden /// until the selection changes, as it does once a markup button consumes the selection. actions_dismissed: Option, @@ -741,6 +744,7 @@ impl DocView { text_failed: HashSet::new(), find: None, selection: None, + all_text: false, actions_dismissed: None, selection_drag: false, clicks: 0, @@ -1171,7 +1175,12 @@ impl DocView { } /// Selected text, if any (⌘C). + /// The selected text: of the pages read so far, after Select All of the document (Copy reads + /// the others, [`DocView::pages_without_text`]). pub fn selected_text(&self) -> Option { + if self.all_text { + return Some(self.whole_text(&HashMap::new())).filter(|x| !x.is_empty()); + } let s = self.selection?; let t = self.texts.get(&s.page)?; Some(s.text(t)).filter(|x| !x.is_empty()) @@ -1349,6 +1358,15 @@ impl DocView { self.select_anchor = (self.page_count > 0).then_some(self.current); return !self.selected.is_empty(); } + // Scrolling continuously, Select All takes the whole document's text (#2639); one page at + // a time, the page shown. + // Like the page's, true once some text is selected: the pages whose text isn't read yet are + // read when the selection is copied. + if self.continuous && self.page_count > 1 { + self.selection = None; + self.all_text = true; + return self.selected_text().is_some(); + } let page = self.current; let Some(t) = self.texts.get(&page) else { return false }; if t.glyphs.is_empty() { @@ -1358,6 +1376,29 @@ impl DocView { true } + /// Pages whose text [`DocView::whole_text`] still needs: not read yet, nor failed. + pub(crate) fn pages_without_text(&self) -> Vec { + (0..self.page_count).filter(|p| !self.texts.contains_key(p) && !self.text_failed.contains(p)).collect() + } + + /// The whole document's text in page order, one page after another on new lines, from the + /// pages' text read so far and `more` (pages read for this, keyed by page). + pub(crate) fn whole_text(&self, more: &HashMap>) -> String { + let mut out = String::new(); + for p in 0..self.page_count { + let Some(t) = self.texts.get(&p).or_else(|| more.get(&p)) else { continue }; + let text = t.text_of(0..t.glyphs.len()); + if text.is_empty() { + continue; + } + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&text); + } + out + } + /// Rotate the view 90° clockwise or counter-clockwise (View ▸ Rotate View, ⇧⌘+ / ⇧⌘−). pub fn rotate_view(&mut self, clockwise: bool) { self.rotation = (self.rotation + if clockwise { 90 } else { 270 }) % 360; @@ -2288,8 +2329,9 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s if key(Key::Escape) { if view.camera.is_some() || view.camera_done.is_some() { view.pending_action = Some(ViewAction::CancelCamera); - } else if view.selection.is_some() { + } else if view.selection.is_some() || view.all_text { view.selection = None; + view.all_text = false; } else { view.find = None; } @@ -3278,6 +3320,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } } + if view.all_text { + for lr in text.line_rects(0..text.glyphs.len()) { + painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } + } if let Some(sel) = view.selection.filter(|s| s.page == i) { for lr in sel.rects(&text) { painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); @@ -3326,6 +3373,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { .is_some_and(|o| r.contains(o)); let pressed = ui.input(|inp| inp.pointer.primary_pressed()); if pressed && press_here && !over_link { + // A press on a page starts over: Select All of the document is dropped. + view.all_text = false; let now = ui.input(|inp| inp.time); // The same limits egui uses to count clicks on release. let (max_delay, max_dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); @@ -3859,7 +3908,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } }); } - // Text layers: visible pages for selection, every page while a search is active. + // Text layers: visible pages for selection, every page while a search is active. (Select All + // of the document needs no more: shown pages highlight as their text comes, and Copy reads + // the rest, as text far away is let go to bound memory.) let need_text = |p: &usize| !view.texts.contains_key(p) && !view.text_failed.contains(p); let mut text_pages: Vec = if hand { Vec::new() } else { visible_now.iter().copied().filter(need_text).collect() }; if view.find.as_ref().is_some_and(|f| !f.case_query.trim().is_empty()) { @@ -5657,6 +5708,92 @@ trailer << /Root 1 0 R >> assert!(v.text_failed.contains(&0) && v.text_failed.contains(&99), "failures are bytes-free: never evict"); } + /// A PDF of one line of text on each page, `lines[i]` on page `i`. + fn text_pages_pdf(lines: &[&str]) -> Vec { + let n = lines.len(); + // 1 catalog, 2 pages, 3 font, then a page and its content stream for each line. + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Count {n} /Kids [{}] >>", (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect::>().join(" ")), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + ]; + for (i, line) in lines.iter().enumerate() { + let content = format!("BT /F1 18 Tf 72 700 Td ({line}) Tj ET"); + objects.push(format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents {} 0 R >>", + 5 + 2 * i + )); + objects.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).bytes()); + } + let xref = out.len(); + out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes()); + for o in offsets { + out.extend(format!("{o:010} 00000 n \n").bytes()); + } + out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes()); + out + } + + /// #2639: scrolling continuously, Select All takes every page's text, and Copy gets all of it + /// in page order, reading the pages whose text wasn't read for display. + #[test] + fn select_all_copies_the_whole_document_while_scrolling_continuously() { + let mut app = PdfCraftApp::new(); + app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap(); + let v = &mut app.views[0]; + assert!(v.continuous, "continuous scrolling is the default"); + assert!(v.texts.is_empty(), "no page's text read yet"); + // Nothing shown is selected yet (false, as for a page whose text isn't read), but the whole + // document is: Copy reads every page. + assert!(!v.select_all()); + assert!(v.all_text && v.selection.is_none()); + assert_eq!(v.pages_without_text(), vec![0, 1, 2]); + let text = app.selection_text(&app.views[0]).expect("text to copy"); + let at = |w: &str| text.find(w).unwrap_or_else(|| panic!("{w:?} missing from {text:?}")); + assert!(at("Alpha") < at("Bravo") && at("Bravo") < at("Charlie"), "page order: {text:?}"); + assert_eq!(text.lines().count(), 3, "a line per page: {text:?}"); + } + + /// Select All keeps to the page shown one page at a time, and in a document of one page; of + /// the document, it reads the text already read and doesn't ask again for failed pages. + #[test] + fn select_all_of_the_document_only_while_scrolling_through_several_pages() { + let info = thumbnail_info(3); + let one_line = |s: &str| PageText { + glyphs: s + .chars() + .map(|c| pdfcraft_render::TextGlyph { text: c.into(), rect: [0.0, 0.0, 10.0, 10.0], quad: [[0.0, 0.0]; 4], direction: [1.0, 0.0] }) + .collect(), + line_of: s.chars().map(|_| 0).collect(), + space_before: s.chars().map(|_| false).collect(), + }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); + for p in 0..3 { + v.texts.insert(p, Arc::new(one_line("abc"))); + } + v.continuous = false; + v.current = 1; + assert!(v.select_all()); + assert!(!v.all_text && v.selection.is_some_and(|s| s.page == 1), "one page at a time: the page shown"); + let single = thumbnail_info(1); + let mut w = DocView::new(DocId(2), &single, ViewDefaults::default()); + w.texts.insert(0, Arc::new(one_line("abc"))); + assert!(w.select_all()); + assert!(!w.all_text && w.selection.is_some(), "a one-page document: that page"); + // Text read for display is used; pages that failed aren't asked for again. + let mut d = DocView::new(DocId(3), &info, ViewDefaults::default()); + d.texts.insert(1, Arc::new(one_line("abc"))); + d.text_failed.insert(2); + assert!(d.select_all() && d.all_text); + assert_eq!(d.pages_without_text(), vec![0]); + } + #[test] fn text_layers_keep_the_selection_page() { let info = thumbnail_info(100); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index b769c0c00c..52766620b3 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -86,7 +86,10 @@ impl PdfCraftApp { } commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id)) && (spec.id != "edit.copy" - || self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.organize && v.selected_text().is_some_and(|t| !t.is_empty()))) + || self + .active + .and_then(|i| self.views.get(i)) + .is_some_and(|v| !v.organize && (v.all_text || v.selected_text().is_some_and(|t| !t.is_empty())))) && (spec.needs != commands::Needs::TwoPageView || self.active.and_then(|i| self.views.get(i)).is_some_and(crate::DocView::cover_applies)) && match spec.id { "view.previous_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.back.is_empty()), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9db9247591..2923788dea 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2886,9 +2886,33 @@ impl PdfCraftApp { self.copy_selected_text(i, ctx); } + /// The text Copy takes from view `v`: its selection, or after Select All of the document every + /// page's text, the pages not read for display yet read here (#2639). + fn selection_text(&self, v: &DocView) -> Option { + if !v.all_text { + return v.selected_text(); + } + let missing = v.pages_without_text(); + let mut more = std::collections::HashMap::new(); + if !missing.is_empty() + && let Some(doc) = self.session.get(v.id) + { + let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() }; + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); + for page in missing { + let req = + pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }; + if let Some(t) = r.render(req).text { + more.insert(page, t); + } + } + } + Some(v.whole_text(&more)) + } + /// Shared permission gate for copying selected page text from menus and keyboard events. pub(crate) fn copy_selected_text(&mut self, i: usize, ctx: &egui::Context) -> bool { - let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, v.selected_text()?))).filter(|(_, text)| !text.is_empty()) else { + let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, self.selection_text(v)?))).filter(|(_, text)| !text.is_empty()) else { return false; }; if !self.may_copy(id) { diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index d41041e52e..23451ee2a7 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2209,6 +2209,11 @@ fn copying_out_of_a_document_honours_its_security() { app.system_clipboard = false; app.open_bytes("no-copy.pdf", None, protected("pw", "owner", -1 ^ 0b1_0000)).unwrap(); app.submit_password(Some(password.into())); + // One page at a time: Select All takes that page's text (scrolling continuously it would + // take both pages', #2639; what is tested here is the security of copying it). + if let Some(v) = app.views.first_mut() { + v.set_continuous(false); + } app }); // Select the page's text once its text layer is loaded (up to ~4 s: a loaded test machine diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index b154694854..224e2ff8a5 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -91,7 +91,9 @@ fn view_history_select_all_and_find_options() { assert_eq!(h.state().views[0].current, 0); assert!(h.state_mut().views[0].view_history(true)); assert_eq!(h.state().views[0].current, 3, "next view"); - // Select all on the current page (once its text is known). + // Select all on the current page (once its text is known), one page at a time: scrolling + // continuously it takes the whole document's text (#2639). + h.state_mut().views[0].set_continuous(false); // Background rendering and text extraction can take seconds on a loaded CI runner (FreeBSD VM). for _ in 0..3000 { h.run_steps(2); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8b..9ddac693cd 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1543,15 +1543,15 @@ notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy hono [[feature]] id = "view.select-all" -title = "Select all text on a page" +title = "Select all text" area = "B" tier = "P1" milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options"] -notes = "⌘A on the current page" +evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/src/canvas.rs::select_all_copies_the_whole_document_while_scrolling_continuously", "crates/ui-egui/src/canvas.rs::select_all_of_the_document_only_while_scrolling_through_several_pages"] +notes = "⌘A: the current page one page at a time; the whole document scrolling continuously (#2639), copied whole" [[feature]] id = "view.copy-with-layout" From b6cd6b44ca57e82ffc90580a17737a95899ab6b6 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:24:10 -0400 Subject: [PATCH 040/517] M6: doc comments for the functions around this change --- crates/automation/src/a11y.rs | 9 +++++++++ crates/engine/src/js.rs | 1 + crates/js/src/lib.rs | 9 +++++++++ crates/js/src/tests.rs | 10 ++++++++++ crates/js/src/xfa.rs | 11 +++++++++++ crates/ui-egui/tests/forms.rs | 3 +++ 6 files changed, 43 insertions(+) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 19afb16a45..a11f1132f9 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -575,6 +575,8 @@ impl Automation { } impl Automation { + /// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused + /// unless the models read it) and `skip_text_pages`, each left at its default when absent. pub(crate) fn ocr_settings(&self, a: &Args) -> Result { let mut settings = pdfcraft_engine::ocr::OcrSettings::default(); if let Some(d) = a.opt_num("dpi")? { @@ -592,6 +594,9 @@ impl Automation { Ok(settings) } + /// Recognizes text in each of `paths` and writes the result under the same file name in + /// `folder`, reporting per file its output, word count and skipped pages, or its error. A + /// path outside the root refuses the whole call before any work starts. pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result { let settings = self.ocr_settings(a)?; let folder = self.resolve(a.str("folder")?, true)?; @@ -623,6 +628,8 @@ impl Automation { Ok(json!({ "files": out })) } + /// Recognizes text on the open document's `pages` (every page when absent) and adds it, + /// reporting the total word count and, per page, why it was skipped or what it found. pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { let settings = self.ocr_settings(a)?; let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; @@ -638,6 +645,8 @@ impl Automation { Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) } + /// Whether text recognition's models are installed, the directories searched for them (in + /// order), and the languages it reads, each as a code and a name. pub(crate) fn ocr_status(&self) -> Result { use pdfcraft_engine::ocr; let dirs: Vec = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect(); diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 9b057900fc..025037b8f1 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -22,6 +22,7 @@ pub struct JsOutput { } impl JsOutput { + /// Whether scripts produced nothing: no alerts, console lines, requests or errors. pub fn is_empty(&self) -> bool { self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty() } diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 1eb3d60e89..6b4e7f8750 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -573,6 +573,8 @@ fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult { } } +/// `app.alert(cMsg)`: records the message, given as a string or as an object with `cMsg`, and +/// returns 1 (the OK button). fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = message(&arg(args, 0), "cMsg", ctx)?; host(ctx)?.borrow_mut().alerts.push(m); @@ -580,11 +582,14 @@ fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Ok(JsValue::from(1)) } +/// `app.beep()`: queues a request to beep. fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { host(ctx)?.borrow_mut().requests.push(Request::Beep); Ok(JsValue::undefined()) } +/// `app.launchURL(cURL)`: queues a request to open the URL, given as a string or as an object +/// with `cURL`. fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = message(&arg(args, 0), "cURL", ctx)?; host(ctx)?.borrow_mut().requests.push(Request::LaunchUrl(u)); @@ -618,6 +623,8 @@ fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult< Ok(JsValue::undefined()) } +/// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to +/// type one. fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { // No one to answer: as if the dialog was cancelled. Ok(JsValue::null()) @@ -920,6 +927,8 @@ fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Ok(s(&out)) } +/// The `color` object: each named color (`transparent`, `black`, `red`, `cyan`, `gray`, …) as +/// an array of its color space (`T`, `G`, `RGB` or `CMYK`) followed by its components. fn color_object(ctx: &mut Context) -> JsObject { let mut entries: Vec<(&str, Vec)> = vec![ ("transparent", vec![s("T")]), diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index a78ce67de7..e3ce86efe8 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1040,6 +1040,8 @@ mod session { use crate::xfa::*; use crate::xfa_session::{Script, XfaSession}; + /// A JavaScript or FormCalc script for `activity` on `target`, against the test form with + /// default limits and a 3 s timeout. fn script(source: &str, formcalc: bool, target: &str, activity: &str) -> Script { Script { source: source.into(), @@ -1051,6 +1053,9 @@ mod session { } } + /// JavaScript and FormCalc scripts run in turn in one session, each on the form the caller + /// sends: nothing an earlier script changed or defined leaks into the next, a failing script + /// doesn't end the session, and hundreds of scripts run on its one thread. #[test] fn scripts_of_one_event_run_on_one_thread_each_on_the_form_the_caller_sends() { let mut s = XfaSession::new(XfaDoc { page_count: 3, ..Default::default() }); @@ -1090,6 +1095,8 @@ mod session { assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); } + /// A JavaScript or FormCalc script whose engine takes longer to be ready than the script's + /// time limit still runs and returns its result, and the session stays alive. #[test] fn a_scripts_time_starts_when_its_context_is_ready() { // The engine made slow to be ready (300 ms) under a 50 ms script limit: the script is @@ -1110,6 +1117,9 @@ mod session { assert_eq!(s.dead(), None); } + /// An endless JavaScript loop past its 200 ms limit is abandoned within 2 s and leaves the + /// session dead, so nothing more runs in it; an endless FormCalc loop is abandoned or + /// reported as running too long. #[test] fn a_script_past_its_time_is_abandoned_and_the_session_is_dead_after_it() { let mut s = XfaSession::new(XfaDoc::default()); diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 093ba633b8..6b87b2e571 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -997,17 +997,22 @@ fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult { // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── +/// Records effect `e` on the host, as [`XHost::push`] does. fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { host(ctx)?.borrow_mut().push(e); Ok(()) } +/// `xfa.host.messageBox(message)`: records the message, cut to [`MAX_LINE`] characters, and +/// returns 1. fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = clip(text(&arg(args, 0), ctx)?); effect(ctx, XfaEffect::MessageBox(m))?; Ok(JsValue::from(1.0)) } +/// `xfa.host.resetData(names)`: asks to reset the comma-separated fields, or every field when +/// the list is empty. fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let list = text(&arg(args, 0), ctx)?; let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); @@ -1015,22 +1020,28 @@ fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { effect(ctx, XfaEffect::Print)?; Ok(JsValue::undefined()) } +/// `xfa.host.beep()`: asks to beep. fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Beep)?; Ok(JsValue::undefined()) } +/// `xfa.host.gotoURL(url)`: asks to open the URL. fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = text(&arg(args, 0), ctx)?; effect(ctx, XfaEffect::LaunchUrl(u))?; Ok(JsValue::undefined()) } +/// `xfa.host.setFocus(target)`: asks to focus the node, given as a node object or as a SOM +/// expression resolved from the current node; an expression that names no node is passed on +/// as written. fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let target = arg(args, 0); let som = match target.as_object() { diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 47a5c8b521..85b0ac03d0 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1050,6 +1050,9 @@ fn on_screen_fit(h: &Harness<'static, PdfCraftApp>, name: &str, text: &str) -> ( } } +/// Text typed into an auto-size field shrinks to fit the box's width, while short text keeps a +/// larger size; a multiline auto-size field wraps and shrinks only as far as the box's height +/// needs, and a fixed 10 pt field keeps its size. #[test] fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() { let mut h = auto_size_harness(); From ab7b5848116c3887fecae09d2e7f1791fe5dd1e9 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 20 Oct 2026 19:25:01 +0500 Subject: [PATCH 041/517] Redaction: follow nested forms as deep as the readers do A form past the pass's depth limit is removed rather than walked. That is the fail-safe choice for a redaction -- marked content inside a form we cannot read would otherwise survive -- but it also removes whatever else that form drew, marked or not. At a limit of 12, a page with a 13-deep chain lost the text of its innermost form with nothing in it marked. Measured on a page that draws HEAD, a chain of forms whose innermost draws DEEP, then TAIL, with only HEAD marked: chain before after (12) after (50) 1 HEAD DEEP TAIL DEEP TAIL DEEP TAIL 12 HEAD DEEP TAIL DEEP TAIL DEEP TAIL 13 HEAD DEEP TAIL TAIL DEEP TAIL 14 HEAD DEEP TAIL TAIL DEEP TAIL 50 HEAD DEEP TAIL TAIL DEEP TAIL 51 HEAD TAIL TAIL TAIL The limit is now 50, which is what the renderer follows (MAX_NESTED_INTERPRETATION_DEPTH) and what Edit text's reader follows (text::MAX_FORM_DEPTH). So anything a reader can see, this pass can walk. The last row is why 50 and not more: at 51 the renderer does not draw DEEP either, so removing that form costs nothing visible and the fail-safe still holds. Fixes #3645's data loss. The other half of that report -- that nothing tells the user when a form is removed -- is untouched: Report.forms_removed is counted but the engine's edit pipeline does not carry it out, and the one channel that reaches the result (doc.edit_notices) is typed TextNotice, for text that could not be drawn. Widening that type or adding a channel is a design choice for the maintainers rather than something to slip into this fix. --- crates/redact/src/interp.rs | 10 ++++++- crates/redact/src/tests.rs | 53 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 52ca6c276f..3d03878e8b 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -26,7 +26,15 @@ use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, Stream}; use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; -const MAX_DEPTH: usize = 12; +/// How deep this pass follows form XObjects. A form past it is removed rather than walked, which +/// is the fail-safe choice for a redaction -- marked content inside a form we cannot read would +/// otherwise survive -- but it also removes whatever else that form drew, marked or not. So the +/// limit has to reach as deep as anything the reader can see: the renderer follows forms to 50 +/// (`MAX_NESTED_INTERPRETATION_DEPTH`) and so does Edit text's reader (`text::MAX_FORM_DEPTH`). +/// At 12, a page with a 13-deep chain lost the text of its innermost form even with nothing in it +/// marked (#3645); past 50 nothing is drawn by any of the three, so removing it costs nothing +/// visible. +const MAX_DEPTH: usize = 50; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Mode { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index da1ecf12ab..9d16fd534c 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -288,6 +288,59 @@ fn vectors_are_removed_or_clipped_and_inline_images_go() { assert!(c.contains("150 150 m"), "the line elsewhere stays"); } +/// A chain of nested forms deeper than the pass can walk was removed from the page, taking +/// whatever else it drew with it even when nothing in it was marked. The limit now reaches as +/// deep as the renderer and Edit text read (50), so anything a reader can see can be walked; +/// past that nothing is drawn either, so removing it costs nothing visible (#3645). +#[test] +fn a_deeply_nested_form_keeps_the_text_nobody_marked() { + /// A page that draws `HEAD` at the top, then a chain of `depth` forms whose innermost draws + /// `DEEP`, then `TAIL`. Only `HEAD` is marked. + fn page_with_chain(depth: usize) -> Document { + let first = 7; + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 {first} 0 R >> >> >>" + ) + .into_bytes(), + stream("", b"BT /F1 10 Tf 10 280 Td (HEAD) Tj ET /Fm1 Do BT /F1 10 Tf 10 10 Td (TAIL) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + ]; + for k in 1..=depth { + let res = if k < depth { + format!("/Resources << /Font << /F1 5 0 R >> /XObject << /Fm{} {} 0 R >> >>", k + 1, first + k) + } else { + "/Resources << /Font << /F1 5 0 R >> >>".to_string() + }; + let body: Vec = if k < depth { format!("/Fm{} Do", k + 1).into_bytes() } else { b"BT /F1 10 Tf 10 150 Td (DEEP) Tj ET".to_vec() }; + objs.push(stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] {res}"), &body)); + } + pdf(objs) + } + + // Deeper than the old limit of 12 and within the new one: the innermost text survives. + for depth in [1, 12, 13, 20, 50] { + let mut doc = page_with_chain(depth); + mark(&mut doc, 0, &[[5.0, 275.0, 60.0, 295.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.forms_removed, 0, "depth {depth}: a form was removed"); + let c = content(&doc, 0); + assert!(c.contains("(TAIL)"), "depth {depth}: {c}"); + // The innermost form's text is still in the saved file. + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(saved.windows(6).any(|w| w == b"(DEEP)"), "depth {depth}: the innermost form's text was deleted"); + } + + // Past what any reader follows, the form is still removed: the fail-safe, and nothing the + // renderer draws is lost because it stops at 50 too. + let mut doc = page_with_chain(51); + mark(&mut doc, 0, &[[5.0, 275.0, 60.0, 295.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().forms_removed, 1, "a chain past 50 is removed"); +} + #[test] fn shared_form_xobjects_are_copied_not_changed() { let form = From 1f182ff0bf32b3feaafee7b1d73a57a549b6f8b5 Mon Sep 17 00:00:00 2001 From: SBaksa Date: Wed, 21 Oct 2026 04:24:30 +1100 Subject: [PATCH 042/517] Select All of the document: copy off the UI thread, end it on any page press - Copy checks the document's copying permission first, then reads the pages whose text wasn't read for display on a worker thread (DocumentCopy; in place on the web), keeping only each page's text as it goes; the clipboard gets the text on a later frame. A new copy or the document closing cancels it. - Any press on a page ends Select All of the document, whatever the tool and whether its text is read; so do switching to one page at a time, a page selection, and an edit of the document. --- crates/ui-egui/src/canvas.rs | 91 +++++++++++++++++- crates/ui-egui/src/lib.rs | 180 +++++++++++++++++++++++++++++------ 2 files changed, 241 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b7abd2bb79..36552ddd1a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -981,6 +981,7 @@ impl DocView { self.waiting_since.clear(); self.last_queue.clear(); self.selection = None; + self.all_text = false; if let Some(f) = self.find.as_mut() { f.matches.clear(); f.current = None; @@ -1067,6 +1068,7 @@ impl DocView { /// Select text on `page` from glyph `from` to glyph `to` (tests and automation). pub fn select_text(&mut self, page: usize, from: usize, to: usize) { + self.all_text = false; self.selection = Some(Selection::run(page, from, to)); } @@ -1367,6 +1369,7 @@ impl DocView { self.all_text = true; return self.selected_text().is_some(); } + self.all_text = false; let page = self.current; let Some(t) = self.texts.get(&page) else { return false }; if t.glyphs.is_empty() { @@ -1435,6 +1438,8 @@ impl DocView { pub fn set_continuous(&mut self, continuous: bool) { if self.continuous != continuous { self.continuous = continuous; + // Select All of the document belongs to continuous scrolling. + self.all_text = false; self.goto = Some((self.current, 0.0)); self.reset_coarse_state(); } @@ -3301,6 +3306,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); + // Any press on the page ends Select All of the document: text read or not, any tool, + // on a link too. + if view.all_text && ui.input(|inp| inp.pointer.primary_pressed() && inp.pointer.press_origin().is_some_and(|o| r.contains(o))) { + view.all_text = false; + } // Text layer: find matches, selection, I-beam and drag-to-select. let to_screen = |g: [f32; 4]| xf.view_rect(g); if let Some(text) = view.texts.get(&i).cloned() { @@ -3373,8 +3383,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { .is_some_and(|o| r.contains(o)); let pressed = ui.input(|inp| inp.pointer.primary_pressed()); if pressed && press_here && !over_link { - // A press on a page starts over: Select All of the document is dropped. - view.all_text = false; let now = ui.input(|inp| inp.time); // The same limits egui uses to count clicks on release. let (max_delay, max_dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); @@ -5754,10 +5762,87 @@ trailer << /Root 1 0 R >> assert!(!v.select_all()); assert!(v.all_text && v.selection.is_none()); assert_eq!(v.pages_without_text(), vec![0, 1, 2]); - let text = app.selection_text(&app.views[0]).expect("text to copy"); + // Copy reads the pages off the UI thread; the clipboard gets the text once they're read. + let ctx = egui::Context::default(); + assert!(app.copy_selected_text(0, &ctx), "the copy starts"); + assert!(app.document_copy.is_some(), "reading the pages, not on this thread"); + let mut text = None; + for _ in 0..500 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.finish_document_copy(ui.ctx())); + out.textures_delta.clear(); + text = out.platform_output.commands.iter().find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }); + if text.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let text = text.expect("the copied text arrives"); + assert!(app.document_copy.is_none(), "done"); let at = |w: &str| text.find(w).unwrap_or_else(|| panic!("{w:?} missing from {text:?}")); assert!(at("Alpha") < at("Bravo") && at("Bravo") < at("Charlie"), "page order: {text:?}"); assert_eq!(text.lines().count(), 3, "a line per page: {text:?}"); + // A new scope ends it: one page at a time, Select All is the page's again. + app.views[0].set_continuous(false); + assert!(!app.views[0].all_text, "not after switching to one page at a time"); + // So do a page selection and a change of the document's content (an edit, undo). + app.views[0].set_continuous(true); + app.views[0].select_all(); + app.views[0].select_text(0, 0, 0); + assert!(!app.views[0].all_text, "a page selection replaces it"); + app.views[0].select_all(); + app.views[0].invalidate_content(); + assert!(!app.views[0].all_text, "not past an edit of the document"); + } + + /// A press anywhere on a page ends Select All of the document, whatever the tool (here the + /// Hand, which selects no text) and whether the page's text is read yet. + #[test] + fn a_press_on_a_page_ends_select_all_of_the_document() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap(); + app + }); + let mut on_screen = None; + for _ in 0..600 { + h.run_steps(2); + on_screen = h.state().views[0].page_screen_rect(0); + if on_screen.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let r = on_screen.expect("the first page is on screen"); + h.state_mut().quick_tool = crate::QuickTool::Hand; + h.state_mut().views[0].select_all(); + assert!(h.state().views[0].all_text); + let at = r.center(); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + assert!(!h.state().views[0].all_text, "a press on the page ends it"); + } + + /// The pages of a whole-document copy come out in order whatever was read before, and a + /// cancelled copy gives nothing (a new copy or a closed document cancels the old one). + #[test] + fn a_document_copy_reads_in_page_order_and_stops_when_cancelled() { + let mut app = PdfCraftApp::new(); + app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap(); + // The middle page's text read for display already; the others read by the copy. + let doc = app.session.get(app.views[0].id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); + let middle = r.render(RenderRequest { page: 1, kind: RequestKind::Text, ..Default::default() }).text.unwrap(); + app.views[0].texts.insert(1, middle); + let job = || crate::DocumentCopyJob::of(&app.views[0], app.session.get(app.views[0].id).unwrap()); + let text = job().text(&std::sync::atomic::AtomicBool::new(false)).unwrap(); + assert!(text.find("Alpha").unwrap() < text.find("Bravo").unwrap() && text.find("Bravo").unwrap() < text.find("Charlie").unwrap(), "{text:?}"); + assert_eq!(job().text(&std::sync::atomic::AtomicBool::new(true)), None, "cancelled"); } /// Select All keeps to the page shown one page at a time, and in a document of one page; of diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2923788dea..6d1f7e70a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -384,6 +384,121 @@ pub struct PageClip { pub pages: Vec, } +/// One page of a whole-document copy: its text as read for display, to read, or not to try +/// (reading it failed before). +enum CopyPage { + Read(std::sync::Arc), + ToRead, + Skip, +} + +/// What a whole-document copy needs, taken from the view on the UI thread: each page's text +/// layer as far as it was read, and the document to read the others from. +struct DocumentCopyJob { + pages: Vec, + bytes: std::sync::Arc>, + password: Option>, +} + +impl DocumentCopyJob { + fn of(v: &DocView, doc: &pdfcraft_engine::Document) -> DocumentCopyJob { + let missing = v.pages_without_text(); + let pages = (0..v.page_count()) + .map(|p| match v.texts.get(&p) { + Some(t) => CopyPage::Read(t.clone()), + None if missing.contains(&p) => CopyPage::ToRead, + None => CopyPage::Skip, + }) + .collect(); + DocumentCopyJob { pages, bytes: doc.bytes.clone(), password: doc.password.as_deref().map(std::sync::Arc::from) } + } + + /// The document's text in page order, a page per line run. Each page read here is let go + /// once its text is taken, so a large document never holds every page's text layer at once. + /// `None` when `cancel` was set. + fn text(self, cancel: &std::sync::atomic::AtomicBool) -> Option { + let mut renderer: Option = None; + let mut out = String::new(); + let mut push = |text: String| { + if !text.is_empty() { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&text); + } + }; + for (page, p) in self.pages.into_iter().enumerate() { + if cancel.load(std::sync::atomic::Ordering::Relaxed) { + return None; + } + match p { + CopyPage::Read(t) => push(t.text_of(0..t.glyphs.len())), + CopyPage::ToRead => { + let r = renderer.get_or_insert_with(|| { + let config = pdfcraft_render::RenderConfig { password: self.password.clone(), ..Default::default() }; + pdfcraft_render::PageRenderer::new(self.bytes.clone(), config) + }); + let req = pdfcraft_render::RenderRequest { + page, + kind: pdfcraft_render::RequestKind::Text, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }; + if let Some(t) = r.render(req).text { + push(t.text_of(0..t.glyphs.len())); + } + } + CopyPage::Skip => {} + } + } + Some(out) + } +} + +/// A whole-document copy reading its pages off the UI thread (in place on the web, which has no +/// threads). Dropping it (a new copy, the document closed) stops the reading. +struct DocumentCopy { + doc: DocId, + rx: std::sync::mpsc::Receiver, + cancel: std::sync::Arc, +} + +impl DocumentCopy { + fn start(doc: DocId, job: DocumentCopyJob, ctx: &egui::Context) -> DocumentCopy { + let (tx, rx) = std::sync::mpsc::channel(); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + #[cfg(not(target_arch = "wasm32"))] + { + let (stop, ctx) = (cancel.clone(), ctx.clone()); + let spawned = std::thread::Builder::new().name("copy-document-text".into()).spawn(move || { + if let Some(text) = job.text(&stop) { + let _ = tx.send(text); + ctx.request_repaint(); + } + }); + if let Err(e) = spawned { + log::warn!("copying the document's text: {e}"); + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = ctx; + if let Some(text) = job.text(&cancel) { + let _ = tx.send(text); + } + } + DocumentCopy { doc, rx, cancel } + } +} + +impl Drop for DocumentCopy { + fn drop(&mut self) { + self.cancel.store(true, std::sync::atomic::Ordering::Relaxed); + } +} + /// Files delivered asynchronously: (name, bytes). pub type Inbox = std::sync::Arc)>>>; @@ -602,6 +717,8 @@ pub struct PdfCraftApp { /// Whether the window currently has the app's header bar (`None` until the first frame). app_frame_shown: Option, pub password_prompt: Option, + /// Copy of a whole document's text (#2639) whose pages are being read off the UI thread. + document_copy: Option, pub unlock_prompt: Option, pub full_screen: bool, /// Files delivered asynchronously (web drag-and-drop, web file picker). @@ -957,6 +1074,7 @@ impl PdfCraftApp { system_title_bar: false, app_frame_shown: None, password_prompt: None, + document_copy: None, unlock_prompt: None, full_screen: false, inbox: Default::default(), @@ -2852,6 +2970,7 @@ impl PdfCraftApp { object_ui::nudge_keys(ctx, &mut self.views[i], doc); } canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed); + self.finish_document_copy(ctx); self.copy_key(i, ctx); self.image_pick_keys(i, ctx); } @@ -2886,35 +3005,22 @@ impl PdfCraftApp { self.copy_selected_text(i, ctx); } - /// The text Copy takes from view `v`: its selection, or after Select All of the document every - /// page's text, the pages not read for display yet read here (#2639). - fn selection_text(&self, v: &DocView) -> Option { - if !v.all_text { - return v.selected_text(); - } - let missing = v.pages_without_text(); - let mut more = std::collections::HashMap::new(); - if !missing.is_empty() - && let Some(doc) = self.session.get(v.id) - { - let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() }; - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); - for page in missing { - let req = - pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }; - if let Some(t) = r.render(req).text { - more.insert(page, t); - } - } - } - Some(v.whole_text(&more)) - } - /// Shared permission gate for copying selected page text from menus and keyboard events. + /// After Select All of the document (#2639), pages whose text wasn't read for display are read + /// off the UI thread ([`DocumentCopy`]), and the clipboard gets the text when they're done. pub(crate) fn copy_selected_text(&mut self, i: usize, ctx: &egui::Context) -> bool { - let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, self.selection_text(v)?))).filter(|(_, text)| !text.is_empty()) else { - return false; - }; + let Some(v) = self.views.get(i) else { return false }; + let id = v.id; + if v.all_text && !v.pages_without_text().is_empty() { + // Checked before any page is read: a document that refuses copying reads nothing. + if !self.may_copy(id) { + return false; + } + let Some(job) = self.views.get(i).and_then(|v| Some(DocumentCopyJob::of(v, self.session.get(id)?))) else { return false }; + self.document_copy = Some(DocumentCopy::start(id, job, ctx)); + return true; + } + let Some(text) = v.selected_text().filter(|text| !text.is_empty()) else { return false }; if !self.may_copy(id) { return false; } @@ -2922,6 +3028,26 @@ impl PdfCraftApp { true } + /// The clipboard text of a whole-document copy, once its pages are read; dropped with the + /// document it was copied from. + fn finish_document_copy(&mut self, ctx: &egui::Context) { + let Some(c) = &self.document_copy else { return }; + if !self.views.iter().any(|v| v.id == c.doc) { + self.document_copy = None; + return; + } + match c.rx.try_recv() { + Ok(text) => { + self.document_copy = None; + if !text.is_empty() { + ctx.copy_text(text); + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(50)), + Err(std::sync::mpsc::TryRecvError::Disconnected) => self.document_copy = None, + } + } + /// The quick tools' keys, as in Acrobat: V selects, H pans, and holding Space pans until it /// is released. Plain letters, so not while a text field, a form field, a dialog or the /// palette has the keyboard. From 3c6be18fb7c2bf68969b54cbbf99f47aeeac0f94 Mon Sep 17 00:00:00 2001 From: SBaksa Date: Wed, 21 Oct 2026 13:57:18 +1100 Subject: [PATCH 043/517] Select All of the document: a later copy cancels one still reading; build the job in one pass A whole-document copy reads its pages off the UI thread, and a copy made meanwhile (a page selection, a text field's, a comment's, an image) went to the clipboard first and was then replaced by the document's text. Any later copy now drops the pending one: copy_selected_text and the image copies drop it directly, and finish_document_copy, now run at the end of the frame, drops it when anything else copied that frame. DocumentCopyJob::of classifies each page from text_failed (a set) instead of looking it up in pages_without_text's Vec, which was quadratic in the page count. --- crates/ui-egui/src/canvas.rs | 39 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/content_ui.rs | 2 ++ crates/ui-egui/src/image_pick.rs | 2 ++ crates/ui-egui/src/lib.rs | 15 +++++++----- 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 36552ddd1a..79e21538e2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5797,6 +5797,45 @@ trailer << /Root 1 0 R >> assert!(!app.views[0].all_text, "not past an edit of the document"); } + /// A whole-document copy reads the pages without text, keeps those already read and skips + /// those that failed before. + #[test] + fn a_document_copy_reads_only_the_pages_it_needs() { + let mut app = PdfCraftApp::new(); + app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap(); + app.views[0].text_failed.insert(2); + let job = crate::DocumentCopyJob::of(&app.views[0], app.session.get(app.views[0].id).unwrap()); + let kinds: Vec<_> = job.pages.iter().map(|p| matches!(p, crate::CopyPage::ToRead)).collect(); + assert_eq!(kinds, vec![true, true, false], "pages 1 and 2 to read, page 3 failed before"); + assert!(matches!(job.pages[2], crate::CopyPage::Skip)); + } + + /// A later copy wins: a whole-document copy still reading its pages never replaces what was + /// copied after it started, from the page or anywhere else (a text field, a comment). + #[test] + fn a_later_copy_cancels_a_document_copy_still_reading() { + let mut app = PdfCraftApp::new(); + app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap(); + let ctx = egui::Context::default(); + // Another copy in the same frame (here a text field's) drops it. + app.views[0].select_all(); + assert!(app.copy_selected_text(0, &ctx) && app.document_copy.is_some()); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.ctx().copy_text("a word".to_owned()); + app.finish_document_copy(ui.ctx()); + }); + out.textures_delta.clear(); + assert!(app.document_copy.is_none(), "cancelled"); + let copies: Vec<_> = out.platform_output.commands.iter().filter(|c| matches!(c, egui::OutputCommand::CopyText(_))).collect(); + assert_eq!(copies, vec![&egui::OutputCommand::CopyText("a word".to_owned())], "the later copy stays"); + // So does copying a page selection. + app.views[0].select_all(); + assert!(app.copy_selected_text(0, &ctx) && app.document_copy.is_some()); + app.views[0].select_text(0, 0, 5); + app.copy_selected_text(0, &ctx); + assert!(app.document_copy.is_none(), "a page selection's copy cancels it"); + } + /// A press anywhere on a page ends Select All of the document, whatever the tool (here the /// Hand, which selects no text) and whether the page's text is read yet. #[test] diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 5f5ef32f77..b1ba7e4f99 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -731,6 +731,8 @@ impl crate::PdfCraftApp { /// Copy Image: image `index` on `page`, at its own resolution, to the clipboard. pub(crate) fn copy_page_image(&mut self, page: usize, index: usize) -> bool { + // A newer copy wins over a whole-document copy still reading its pages. + self.document_copy = None; let Some((_, id)) = self.active_ids() else { return false }; if !self.may_copy(id) { return false; diff --git a/crates/ui-egui/src/image_pick.rs b/crates/ui-egui/src/image_pick.rs index 53e4bdf158..403e9836c0 100644 --- a/crates/ui-egui/src/image_pick.rs +++ b/crates/ui-egui/src/image_pick.rs @@ -183,6 +183,8 @@ impl PdfCraftApp { /// Copy Image in Original Format: image `index` on `page` as a file on the clipboard (JPEG /// as stored in the PDF, otherwise PNG). pub(crate) fn copy_page_image_file(&mut self, page: usize, index: usize) { + // A newer copy wins over a whole-document copy still reading its pages. + self.document_copy = None; let Some((_, id)) = self.active_ids() else { return }; if !self.may_copy(id) { return; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6d1f7e70a6..f2dae65a81 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -402,12 +402,11 @@ struct DocumentCopyJob { impl DocumentCopyJob { fn of(v: &DocView, doc: &pdfcraft_engine::Document) -> DocumentCopyJob { - let missing = v.pages_without_text(); let pages = (0..v.page_count()) .map(|p| match v.texts.get(&p) { Some(t) => CopyPage::Read(t.clone()), - None if missing.contains(&p) => CopyPage::ToRead, - None => CopyPage::Skip, + None if v.text_failed.contains(&p) => CopyPage::Skip, + None => CopyPage::ToRead, }) .collect(); DocumentCopyJob { pages, bytes: doc.bytes.clone(), password: doc.password.as_deref().map(std::sync::Arc::from) } @@ -2970,7 +2969,6 @@ impl PdfCraftApp { object_ui::nudge_keys(ctx, &mut self.views[i], doc); } canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed); - self.finish_document_copy(ctx); self.copy_key(i, ctx); self.image_pick_keys(i, ctx); } @@ -3009,6 +3007,8 @@ impl PdfCraftApp { /// After Select All of the document (#2639), pages whose text wasn't read for display are read /// off the UI thread ([`DocumentCopy`]), and the clipboard gets the text when they're done. pub(crate) fn copy_selected_text(&mut self, i: usize, ctx: &egui::Context) -> bool { + // A newer copy wins: a whole-document copy still reading must not replace it later. + self.document_copy = None; let Some(v) = self.views.get(i) else { return false }; let id = v.id; if v.all_text && !v.pages_without_text().is_empty() { @@ -3029,10 +3029,12 @@ impl PdfCraftApp { } /// The clipboard text of a whole-document copy, once its pages are read; dropped with the - /// document it was copied from. + /// document it was copied from, and by any later copy (a text field's, a comment's), which + /// it must not replace. Run at the end of the frame, so it sees this frame's copies. fn finish_document_copy(&mut self, ctx: &egui::Context) { let Some(c) = &self.document_copy else { return }; - if !self.views.iter().any(|v| v.id == c.doc) { + let copied = ctx.output(|o| o.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(_) | egui::OutputCommand::CopyImage(_)))); + if copied || !self.views.iter().any(|v| v.id == c.doc) { self.document_copy = None; return; } @@ -3355,6 +3357,7 @@ impl eframe::App for PdfCraftApp { impl PdfCraftApp { fn finish_render_frame(&mut self, ctx: &egui::Context) { + self.finish_document_copy(ctx); // The Combine grid's thumbnails, whichever tab shows (renders under way still finish). self.combine_thumbs_frame(ctx); // Retire hidden documents before admitting the visible document's textures, so the From 755266cf9f93b2a7cf1a221e342b733917d8f7c3 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 21 Oct 2026 18:45:04 +0500 Subject: [PATCH 044/517] cli: the argument bugs photocraft#7071 lists are live here too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of them, measured on main: pdfcraft-cli extract f.pdf --pages 1 --out --password (exit 0, and the PDF is in a file called `--password`) pdfcraft-cli check-one --edit f.pdf pdfcraft-cli: check-one: missing file pdfcraft-cli info /tmp/café.pdf (that é as one Latin-1 byte) thread 'main' panicked at library/std/src/env.rs:878:51: called `Result::unwrap()` on an `Err` value - An option whose value is a **file or folder** refuses a value that is another option, and names the way out for a file that really starts with `-`: `./--name`, as `--root` already offered. That also closes the gap the option walk left for those: an unknown option in a value position was skipped along with the option it followed, so `combine --out --nonsense` wrote `--nonsense` instead of saying what `--nonsense` was. Only those options. A password or a title can be any string, `--page` included — `an_option_shaped_password_is_not_parsed_as_a_page_flag` pins that, and a bad `--page` or `--dpi` is reported by the command in its own terms either way. - `positional` asks the option table which tokens are values, instead of skipping the one after every option. The token after a flag that takes none is an argument: `check-one --edit f.pdf` read the file as `--edit`'s operand and then reported it missing. - Arguments come through `args_os`, so one that is not UTF-8 is a usage error rather than a panic. `std::env::args` panics while collecting them, so even `--help` ended that way. tests/options.rs covers each, and each fails without its fix (the file called `--password` comes back; "check-one: missing file"; exit 101 with that report). --- apps/pdfcraft-cli/src/main.rs | 116 ++++++++++++++++++++++------- apps/pdfcraft-cli/tests/options.rs | 81 ++++++++++++++++++++ 2 files changed, 169 insertions(+), 28 deletions(-) diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 5888e51114..3fd67e3bb4 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -84,7 +84,24 @@ help and feedback: https://discord.gg/artcraft" ); fn main() -> ExitCode { - let args: Vec = std::env::args().skip(1).collect(); + // `std::env::args` panics on an argument that is not UTF-8 ("called `Result::unwrap()` on an + // `Err` value"), which a Latin-1 file name is, and it panics while collecting them: even + // `--help` ended that way. Such a name cannot be passed on either, since the paths here are + // `str`, so it is a usage error (photocraft#1108). + let mut args: Vec = Vec::new(); + for arg in std::env::args_os().skip(1) { + match arg.into_string() { + Ok(a) => args.push(a), + Err(bad) => { + let _ = writeln!( + std::io::stderr().lock(), + "pdfcraft-cli: `{}` is not valid UTF-8, so it cannot be used as an argument", + bad.to_string_lossy() + ); + return ExitCode::FAILURE; + } + } + } // ` --help` too; only right after the command, so a later `-h` (a value) still reaches it. let command = match args.get(1).map(String::as_str) { Some("--help" | "-h") => Some("--help"), @@ -169,7 +186,7 @@ fn version() -> Result<(), CliError> { /// found so scripts and CI can gate on it, and when the audit itself cannot run. fn audit(args: &[String]) -> Result<(), CliError> { validate_options("audit", args, AUDIT_OPTIONS)?; - let path = *positional(args).first().ok_or("audit: missing file")?; + let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; @@ -203,6 +220,28 @@ struct OptionSpec { takes_value: bool, } +/// The options whose value is a file or folder. Only these refuse a value that is another +/// option: a file named `--password` is never what was meant, while a password can be anything +/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title. +/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered. +const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"]; + +/// Whether a token can be the value of a path option. Another option cannot: `--out --password` +/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944). +fn can_be_path_value(name: &str, value: Option<&str>) -> bool { + !PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--")) +} + +/// The complaint about a path option whose value is missing or is another option. +fn needs_value(command: &str, name: &str, value: Option<&str>) -> String { + match value { + Some(v) if !v.is_empty() => { + format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name") + } + _ => format!("{command}: {name} needs a value"), + } +} + /// Refuse unknown options and malformed confinement flags before any file is read. fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> { if specs.iter().any(|spec| spec.name == "--root") { @@ -225,9 +264,19 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { return Err(format!("{command}: unknown option --{name}").into()); }; - // Other missing or malformed values are left to the command, which reports them in its own - // terms (`text` and `extract` explain what a page value must be). - i += if spec.takes_value { 2 } else { 1 }; + // A file's name is not another option, which also closes the gap the walk left for + // those: an unknown option in that value position was skipped along with it. Other + // values, missing or malformed, are left to the command, which reports them in its + // own terms (`text` and `extract` explain what a page value must be). + i += if spec.takes_value { + let value = args.get(i + 1).map(String::as_str); + if !can_be_path_value(spec.name, value) { + return Err(needs_value(command, spec.name, value).into()); + } + 2 + } else { + 1 + }; } else { i += 1; } @@ -273,19 +322,24 @@ const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: #[cfg(feature = "mcp")] const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }]; -fn positional(args: &[String]) -> Vec<&str> { +/// The arguments that are not options or their values. Which tokens are values is the spec's +/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing +/// file", the file having been read as `--edit`'s value. +fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> { let mut out = Vec::new(); - let mut skip = false; - for a in args { - if skip { - skip = false; - continue; + let mut i = 0; + while let Some(a) = args.get(i) { + match specs.iter().find(|spec| spec.name == a.as_str()) { + Some(spec) if spec.takes_value => i += 2, + // An option that takes no value, and an unknown one, which `validate_options` has + // already refused by the time this runs. + Some(_) => i += 1, + None if a.starts_with("--") => i += 1, + None => { + out.push(a.as_str()); + i += 1; + } } - if a.starts_with("--") { - skip = true; - continue; - } - out.push(a.as_str()); } out } @@ -296,7 +350,7 @@ fn read(path: &str) -> Result>, String> { fn info(args: &[String]) -> Result<(), CliError> { validate_options("info", args, INFO_OPTIONS)?; - let path = *positional(args).first().ok_or("info: missing file")?; + let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; @@ -347,7 +401,7 @@ fn info(args: &[String]) -> Result<(), CliError> { fn text(args: &[String]) -> Result<(), CliError> { validate_options("text", args, TEXT_OPTIONS)?; - let path = *positional(args).first().ok_or("text: missing file")?; + let path = *positional(args, TEXT_OPTIONS).first().ok_or("text: missing file")?; let mut selected_page = None; // Validate every supplied value before reading, retaining the first valid selection. let mut options = args.iter(); @@ -472,7 +526,13 @@ fn edit(args: &[String]) -> Result<(), CliError> { continue; } } - let value = args.get(i + 1).map(String::as_str).ok_or_else(|| format!("edit: {arg} needs a value"))?; + // `edit` parses its own options (they run in order), so `--out` is checked here in the + // same way: it wrote a PDF to a file called `--full`. + let value = args.get(i + 1).map(String::as_str); + if !can_be_path_value(arg, value) { + return Err(needs_value("edit", arg, value).into()); + } + let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?; match arg { "--out" => out = Some(value), "--password" => password = Some(value), @@ -545,7 +605,7 @@ fn file_stem(path: &str) -> String { fn combine(args: &[String]) -> Result<(), CliError> { validate_options("combine", args, COMBINE_OPTIONS)?; let out = flag(args, "--out").ok_or("combine: missing --out")?; - let inputs = positional(args); + let inputs = positional(args, COMBINE_OPTIONS); if inputs.len() < 2 { return Err("combine: give at least two input files".into()); } @@ -556,7 +616,7 @@ fn combine(args: &[String]) -> Result<(), CliError> { fn extract(args: &[String]) -> Result<(), CliError> { validate_options("extract", args, EXTRACT_OPTIONS)?; - let path = *positional(args).first().ok_or("extract: missing file")?; + let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?; let out = flag(args, "--out").ok_or("extract: missing --out")?; let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?; let mut session = pdfcraft_engine::Session::new(); @@ -568,7 +628,7 @@ fn extract(args: &[String]) -> Result<(), CliError> { fn split(args: &[String]) -> Result<(), CliError> { validate_options("split", args, SPLIT_OPTIONS)?; use pdfcraft_engine::SplitBy; - let path = *positional(args).first().ok_or("split: missing file")?; + let path = *positional(args, SPLIT_OPTIONS).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")?), (None, Some(list)) => SplitBy::Before(page_list(list)?), @@ -592,7 +652,7 @@ fn split(args: &[String]) -> Result<(), CliError> { fn render(args: &[String]) -> Result<(), CliError> { validate_options("render", args, RENDER_OPTIONS)?; - let path = *positional(args).first().ok_or("render: missing file")?; + let path = *positional(args, RENDER_OPTIONS).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")?; @@ -646,7 +706,7 @@ fn render(args: &[String]) -> Result<(), CliError> { /// Child-process body for `check`: prints one JSON line. 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 path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?; let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?; let start = Instant::now(); let bytes = read(path)?; @@ -739,7 +799,7 @@ fn collect(paths: &[&str]) -> Vec { fn check(args: &[String]) -> Result<(), CliError> { validate_options("check", args, CHECK_OPTIONS)?; - let files = collect(&positional(args)); + let files = collect(&positional(args, CHECK_OPTIONS)); if files.is_empty() { return Err("check: no PDF files found".into()); } @@ -924,9 +984,9 @@ fn run(args: &[String]) -> Result<(), CliError> { } return Ok(()); } - let tool = *positional(args).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; + let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; let mut obj = serde_json::Map::new(); - for kv in positional(args).iter().skip(1) { + for kv in positional(args, RUN_OPTIONS).iter().skip(1) { let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?; let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())); obj.insert(k.to_string(), value); @@ -992,7 +1052,7 @@ fn ui(args: &[String]) -> Result<(), CliError> { // Refused rather than truncated, which would quietly reach some other port. let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?; let token = info["token"].as_str().ok_or(format!("{file}: no token"))?; - let pos = positional(args); + let pos = positional(args, UI_OPTIONS); let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?; let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") }; let mut params = serde_json::Map::new(); diff --git a/apps/pdfcraft-cli/tests/options.rs b/apps/pdfcraft-cli/tests/options.rs index 9576d59a8f..14b3e617c6 100644 --- a/apps/pdfcraft-cli/tests/options.rs +++ b/apps/pdfcraft-cli/tests/options.rs @@ -27,3 +27,84 @@ fn non_edit_commands_reject_unknown_options_before_io() { assert!(stderr.contains("unknown option") && stderr.contains(unknown), "{args:?}: {stderr}"); } } + +/// photocraft#6944: an option took the next token whatever it was, so a line with a value missing +/// in the middle of it did something else entirely — `extract in.pdf --pages 1 --out --password` +/// wrote the PDF to a file called `--password` and exited 0. The unknown option hidden in a value +/// position passed the check above too, since the walk skipped it along with the option. +/// +/// Only the options whose value is a file or folder refuse one: a password or a title can be any +/// string, `--page` included, which `text`'s own tests pin and this does not change. +#[test] +fn a_file_option_refuses_a_value_that_is_another_option() { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-values-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + for (option, args) in [ + ("--out", vec!["extract", "missing.pdf", "--pages", "1", "--out", "--password"]), + ("--out", vec!["combine", "a.pdf", "b.pdf", "--out", "--nonsense"]), + ("--out", vec!["render", "missing.pdf", "--out", "--dpi", "96"]), + ("--out-dir", vec!["split", "missing.pdf", "--every", "2", "--out-dir", "--password"]), + ("--json", vec!["check", "missing.pdf", "--json", "--dpi"]), + ("--control", vec!["ui", "--control", "--out", "state"]), + // `edit` parses its own options, in the order given, and had the same gap. + ("--out", vec!["edit", "missing.pdf", "--out", "--full"]), + ] { + let out = Command::new(BIN).current_dir(&dir).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?} unexpectedly succeeded"); + assert!(stderr.contains(&format!("{option} needs a value")), "{args:?}: {stderr}"); + assert!(stderr.contains("another option"), "{args:?}: {stderr}"); + } + assert_eq!(std::fs::read_dir(&dir).expect("read dir").count(), 0, "nothing was written"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// And a file whose name really does start with `-` is still reachable, the way the shell reaches +/// one and `--root` already documented: `./--name`. The error is then about the input file, which +/// is as far as these lines get here. +#[test] +fn a_file_whose_name_starts_with_dashes_is_still_reachable() { + for args in [ + vec!["extract", "missing.pdf", "--pages", "1", "--out", "./--dash.pdf"], + vec!["edit", "missing.pdf", "--out", "./--dash.pdf"], + vec!["info", "./--dash.pdf"], + ] { + let out = Command::new(BIN).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?}: no such file either way"); + assert!(stderr.contains("No such file"), "{args:?}: {stderr}"); + assert!(!stderr.contains("needs a value"), "{args:?}: {stderr}"); + } +} + +/// The token after an option that takes no value is not its value: `check-one --edit file.pdf` +/// reported "check-one: missing file", the file having been read as `--edit`'s operand. +#[test] +fn a_flag_that_takes_no_value_does_not_swallow_the_file() { + for args in [vec!["check-one", "--edit", "missing.pdf"], vec!["check-one", "missing.pdf", "--edit"]] { + let out = Command::new(BIN).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?}"); + assert!(stderr.contains("missing.pdf") && stderr.contains("No such file"), "{args:?}: {stderr}"); + assert!(!stderr.contains("missing file"), "the file was found, and then not found: {stderr}"); + } +} + +/// photocraft#1108: an argument that is not UTF-8 panicked in `std::env::args` ("called +/// `Result::unwrap()` on an `Err` value"), while collecting the arguments — so it panicked even +/// for `--help`. Such a name cannot be used here, where paths are `str`, so it is a usage error. +#[cfg(unix)] +#[test] +fn an_argument_that_is_not_utf8_is_an_error_not_a_panic() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + for args in [vec![OsStr::new("info"), OsStr::from_bytes(b"/tmp/caf\xe9.pdf")], vec![OsStr::new("--help"), OsStr::from_bytes(b"caf\xe9")]] { + let out = Command::new(BIN).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1), "an error, not a panic (101): {stderr}"); + assert!(stderr.contains("not valid UTF-8"), "{stderr}"); + assert!(!stderr.contains("panicked"), "{stderr}"); + } +} From 3bdc4ddcc78c3d492973eea6d2b379aa290dcebc Mon Sep 17 00:00:00 2001 From: Philip Date: Wed, 21 Oct 2026 20:26:24 +0100 Subject: [PATCH 045/517] M10: review of #1609: test the Edit-triggered recognition rules without OCR models FredApps: CI never fetches the OCR models, so the tests that recognize something skip there and the recognition paths this PR adds went untested in CI. The decision is split out of auto_ocr_scanned_page into scan_to_read, which needs no models (the run itself judges the page's own text), with unit tests that run everywhere: a picture page is read and a text or blank page is not; only Edit being on starts it (its tab counts); Read mode and an open dialog wait; a page already read is not read again and a page found no scan is judged again once the document changes. Two mutants (no Read/modal guard; a no-scan page never judged again) each fail one of them. The tests that recognize words still need the models. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ocr_ui.rs | 155 +++++++++++++++++++++++++++++------ 1 file changed, 131 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index dcd3553756..039384da80 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -62,6 +62,15 @@ pub struct BatchProgress { pub message: Option, } +/// A page Edit should read now (see [`PdfCraftApp::scan_to_read`]). +struct ScanToRead { + view: usize, + doc: DocId, + page: usize, + /// The document's edit generation when it was judged. + generation: u64, +} + pub struct OcrRun { pub doc: DocId, pub progress: Arc>, @@ -180,6 +189,27 @@ impl PdfCraftApp { /// background picture) is left alone, and is judged again only when the document changes. It /// is one undoable step, "Recognize text", and is not run again for that page after Undo. pub(crate) fn auto_ocr_scanned_page(&mut self) { + let Some(scan) = self.scan_to_read() else { return }; + if !pdfcraft_engine::ocr::available() { + self.views[scan.view].auto_ocr_skipped.insert(scan.page, scan.generation); + return; + } + self.views[scan.view].auto_ocr_tried.insert(scan.page); + // Only a page with no text of its own is read. Comments and text boxes over the picture do + // not count as its text (a scan with a few comments is still a scan, as in Acrobat), but + // a page with real text and a background picture is left alone. + let mut settings = self.ocr_draft.settings(); + settings.skip_text_pages = true; + settings.native_text_only = true; + self.start_ocr_pages(scan.doc, &[scan.page], settings, true); + } + + /// The page Edit should read now, if there is one: Edit is on outside Read mode with no dialog + /// open, nothing else is being recognized, the document may be changed, the page in view has + /// not been read before (nor been found no scan since the document last changed), and an image + /// covers at least half of it. Needs no OCR models, so the rules can be tested without them; + /// the page's own text is judged by the run itself. + fn scan_to_read(&mut self) -> Option { // Edit is on when its panel is open (the Edit tab) or Edit text & images or Add text is picked, // the same test the page view uses for editing content. let editing = (self.left_open && self.left == crate::LeftPanel::Tool("edit")) @@ -188,41 +218,34 @@ impl PdfCraftApp { // dialog or prompt: nothing may change the document under those. It is tried again once // they are gone. if !editing || self.mode == crate::Mode::Read || self.modal_open() || self.ocr_run.is_some() || self.ocr_batch.is_some() { - return; + return None; } - let Some((vi, id)) = self.active_ids() else { return }; - let page = self.views[vi].current; - if self.views[vi].auto_ocr_tried.contains(&page) { - return; + let (view, doc) = self.active_ids()?; + let page = self.views[view].current; + if self.views[view].auto_ocr_tried.contains(&page) { + return None; } - let Some(doc) = self.session.get(id) else { return }; - if doc.read_only_reason.is_some() || !doc.allows_modification() { - return; + let document = self.session.get(doc)?; + if document.read_only_reason.is_some() || !document.allows_modification() { + return None; } // A page found not to be a scan is judged again only once the document has changed, not // every frame (listing its images parses the page). - let generation = doc.edit_generation(); - if self.views[vi].auto_ocr_skipped.get(&page) == Some(&generation) { - return; + let generation = document.edit_generation(); + if self.views[view].auto_ocr_skipped.get(&page) == Some(&generation) { + return None; } - let Some(info) = doc.info.pages.get(page) else { return }; + let info = document.info.pages.get(page)?; let page_area = f64::from(info.width) * f64::from(info.height); - let covered = doc.page_images(page).iter().any(|i| { + let covered = document.page_images(page).iter().any(|i| { let [x0, y0, x1, y1] = i.visible_rect.unwrap_or(i.rect); page_area > 0.0 && ((x1 - x0) * (y1 - y0)).abs() >= 0.5 * page_area }); - if !covered || !pdfcraft_engine::ocr::available() { - self.views[vi].auto_ocr_skipped.insert(page, generation); - return; + if !covered { + self.views[view].auto_ocr_skipped.insert(page, generation); + return None; } - self.views[vi].auto_ocr_tried.insert(page); - // Only a page with no text of its own is read. Comments and text boxes over the picture do - // not count as its text (a scan with a few comments is still a scan, as in Acrobat), but - // a page with real text and a background picture is left alone. - let mut settings = self.ocr_draft.settings(); - settings.skip_text_pages = true; - settings.native_text_only = true; - self.start_ocr_pages(id, &[page], settings, true); + Some(ScanToRead { view, doc, page, generation }) } /// Recognize `pages` (0-based; empty = all) of document `id` in the background. @@ -433,3 +456,87 @@ impl PdfCraftApp { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Dialog, LeftPanel, Mode, QuickTool}; + use pdfcraft_engine::{Edit, Session}; + + /// A one-page PDF that is one picture covering the page. + fn scan() -> Vec { + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(8, 8, image::Rgba([250, 250, 250, 255]))) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + Session::new().create_from_images(&[("scan.png".into(), png.into_inner())]).unwrap().to_vec() + } + + fn app_with(pdf: Vec) -> PdfCraftApp { + let mut app = PdfCraftApp::new(); + app.open_bytes("doc.pdf", None, pdf).unwrap(); + app.quick_tool = QuickTool::EditText; + app + } + + fn found(app: &mut PdfCraftApp) -> Option { + app.scan_to_read().map(|s| s.page) + } + + /// These rules need no OCR models (the run itself judges the page's own text), so they are + /// tested everywhere, unlike the tests that recognize something. + #[test] + fn edit_reads_a_page_that_is_a_picture_and_no_other() { + assert_eq!(found(&mut app_with(scan())), Some(0)); + let text = Session::new().create_from_text("t", "Real text, no picture").unwrap().to_vec(); + assert_eq!(found(&mut app_with(text)), None, "a page of text is no scan"); + let blank = Session::new().create_blank(595.0, 842.0, 1).unwrap().to_vec(); + assert_eq!(found(&mut app_with(blank)), None, "nor is a blank page"); + } + + #[test] + fn only_edit_being_on_starts_it() { + let mut app = app_with(scan()); + app.quick_tool = QuickTool::Select; + assert_eq!(found(&mut app), None, "another tool"); + // The Edit tab's panel counts, as for the page view, with no tool picked. + app.left = LeftPanel::Tool("edit"); + app.left_open = true; + assert_eq!(found(&mut app), Some(0), "the Edit panel"); + app.left_open = false; + assert_eq!(found(&mut app), None, "the Edit panel closed"); + } + + #[test] + fn it_waits_for_read_mode_and_dialogs_to_end() { + let mut app = app_with(scan()); + app.mode = Mode::Read; + assert_eq!(found(&mut app), None, "Read mode"); + app.mode = Mode::Edit; + app.dialog = Some(Dialog::JsConsole); + assert_eq!(found(&mut app), None, "beneath a dialog"); + app.dialog = None; + assert_eq!(found(&mut app), Some(0), "tried again once they are gone"); + } + + #[test] + fn a_page_is_read_once_and_a_page_found_no_scan_is_judged_again_after_a_change() { + let mut app = app_with(scan()); + app.views[0].auto_ocr_tried.insert(0); + assert_eq!(found(&mut app), None, "a page already read is not read again (Undo sticks)"); + + // A blank page is no scan; it is judged again only once the document changes. + let blank = Session::new().create_blank(595.0, 842.0, 1).unwrap().to_vec(); + let mut app = app_with(blank); + assert_eq!(found(&mut app), None); + assert!(app.views[0].auto_ocr_skipped.contains_key(&0), "remembered with the document's generation"); + let id = app.active_ids().unwrap().1; + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(8, 8, image::Rgba([250, 250, 250, 255]))) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let add = Edit::AddImage { page: 0, rect: Some([0.0, 0.0, 595.0, 842.0]), name: "scan.png".into(), bytes: Arc::new(png.into_inner()) }; + app.session.apply(id, add).unwrap(); + assert_eq!(found(&mut app), Some(0), "a picture over the page makes it a scan"); + } +} From 90c15aa514872e7dfa3e823326b58fbb9cd9cb19 Mon Sep 17 00:00:00 2001 From: Philip Date: Wed, 21 Oct 2026 21:43:58 +0100 Subject: [PATCH 046/517] M10: review of #1609: an automatic run's result waits for Read mode and dialogs CodeRabbit: Edit refuses to start recognition in Read mode or beneath a dialog, but a run started a moment earlier could finish after the user had switched to Read mode or opened a dialog, and poll_ocr applied its result there. The result of a run Edit started is now left until both are gone; a run the user asked for completes as before. The unit test uses a made-up result, so it needs no OCR models, and fails without the check. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ocr_ui.rs | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 039384da80..186e5753c6 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -400,6 +400,16 @@ impl PdfCraftApp { let Some(run) = self.ocr_run.as_ref() else { return }; let (doc, progress, automatic) = (run.doc, run.progress.clone(), run.automatic); let Ok(mut s) = progress.lock() else { return }; + // The result of a run Edit started waits while Read mode or a dialog is up: nothing may change + // the document beneath them, and the run may have been started before they appeared. A run + // the user asked for completes as before. + if automatic && s.result.is_some() && (self.mode == crate::Mode::Read || self.modal_open()) { + drop(s); + if let Some(ctx) = &self.ctx { + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } + return; + } let Some(result) = s.result.take() else { let msg = crate::i18n::fmt( tl!("Recognizing text… page {d} of {t}"), @@ -539,4 +549,37 @@ mod tests { app.session.apply(id, add).unwrap(); assert_eq!(found(&mut app), Some(0), "a picture over the page makes it a scan"); } + /// A finished run Edit started is applied only once Read mode and dialogs are gone; one the user + /// asked for is applied at once. The result is made up, so no OCR models are needed. + #[test] + fn the_result_of_an_automatic_run_waits_for_read_mode_and_dialogs() { + let word = || pdfcraft_engine::ocr::PlacedWord { text: "scanned".into(), origin: [72.0, 700.0], across: [60.0, 0.0], up: [0.0, 12.0] }; + let finished = |app: &PdfCraftApp, automatic: bool| { + let page = pdfcraft_engine::ocr::OcrPage { page: 0, words: vec![word()], already_on_page: 0, skipped: None }; + let progress = Arc::new(Mutex::new(OcrProgress { done: 1, total: 1, result: Some(Ok(vec![page])), cancel: false })); + OcrRun { doc: app.active_ids().unwrap().1, progress, automatic } + }; + let label = |app: &PdfCraftApp| app.session.get(app.active_ids().unwrap().1).unwrap().can_undo().map(String::from); + + let mut app = app_with(scan()); + app.ocr_run = Some(finished(&app, true)); + app.mode = Mode::Read; + app.poll_ocr(); + assert!(app.ocr_run.is_some() && label(&app).is_none(), "waits in Read mode"); + app.mode = Mode::Edit; + app.dialog = Some(Dialog::JsConsole); + app.poll_ocr(); + assert!(app.ocr_run.is_some() && label(&app).is_none(), "waits beneath a dialog"); + app.dialog = None; + app.poll_ocr(); + assert!(app.ocr_run.is_none(), "taken once they are gone"); + assert_eq!(label(&app).as_deref(), Some("Recognize text")); + + // A run the user asked for is not held back. + let mut app = app_with(scan()); + app.ocr_run = Some(finished(&app, false)); + app.mode = Mode::Read; + app.poll_ocr(); + assert_eq!(label(&app).as_deref(), Some("Recognize text")); + } } From ad269330f27e690acc7ffa95d9c982cf085b2e94 Mon Sep 17 00:00:00 2001 From: Osvaldo Duran Melendez Date: Wed, 21 Oct 2026 19:39:48 -0400 Subject: [PATCH 047/517] Edit > Attach a file: drop the pick if the active document changed while choosing The picker passed no target, so the staleness guard never ran and a file chosen after switching tabs was attached to the newly active document. Pass the active document like Import data and the image picker do. A test covers the tab switch. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 3 +- crates/ui-egui/tests/attach_picker_target.rs | 66 ++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/attach_picker_target.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7fd075dd86..44993b6ca6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1535,7 +1535,8 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] { let dialog = rfd::AsyncFileDialog::new().set_title(tl!("Attach a file").to_string()); - self.ask_one(pickers::Ask::File(dialog), None, |app, path| app.attach_file(&path)); + let target = self.active_ids().map(|(_, id)| id); + self.ask_one(pickers::Ask::File(dialog), target, |app, path| app.attach_file(&path)); } #[cfg(target_arch = "wasm32")] self.notify_tr("Attaching files is not available on the web yet"); diff --git a/crates/ui-egui/tests/attach_picker_target.rs b/crates/ui-egui/tests/attach_picker_target.rs new file mode 100644 index 0000000000..ac1aed7361 --- /dev/null +++ b/crates/ui-egui/tests/attach_picker_target.rs @@ -0,0 +1,66 @@ +//! Edit ▸ Attach a file… attaches to the document that was active when the picker opened. If the +//! user switches tabs while choosing, the pick is dropped (and they are told) rather than the file +//! landing in whichever document is active by then. + +use egui_kittest::Harness; +use pdfcraft_ui_egui::PdfCraftApp; + +/// A one-page document with a proper xref table. +fn fixture() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("a.pdf", None, fixture()).unwrap(); + app.open_bytes("b.pdf", None, fixture()).unwrap(); + app + }); + h.run_steps(2); + h +} + +fn dirty(app: &PdfCraftApp, view: usize) -> bool { + app.session.get(app.views[view].id).is_some_and(|d| d.dirty) +} + +#[test] +fn an_attachment_pick_is_dropped_when_the_user_switches_tabs() { + let dir = std::env::temp_dir().join(format!("pdfcraft-attach-target-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("note.txt"); + std::fs::write(&file, b"hello").unwrap(); + + let mut h = harness(); + h.state_mut().active = Some(0); + h.state_mut().pick_override = Some(vec![file.to_string_lossy().into_owned()]); + assert!(h.state_mut().execute("edit.attach")); + // The user switches tabs before the pick arrives. + h.state_mut().active = Some(1); + h.run_steps(2); + assert!(!dirty(h.state(), 0), "the document the pick was for is unchanged"); + assert!(!dirty(h.state(), 1), "the newly active document did not get the attachment"); + let toast = h.state().toast.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert!(toast.contains("document changed"), "the user is told why nothing happened: {toast:?}"); + let _ = std::fs::remove_dir_all(&dir); +} From 4ee87801b255b0d96bc0ba64c9ed187d432d5f5c Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:48:31 -0400 Subject: [PATCH 048/517] M6: util.scand's linear-time test allows a loaded runner (10 s) --- crates/js/src/tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index b871ed6c3f..d155251638 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1293,7 +1293,8 @@ mod scand { } /// A format of 50,000 literal letters and one of 100,000 fields, with texts to match, are read - /// in under two seconds. + /// in linear time. The limit is generous so a loaded runner passes (it also covers starting + /// the engine and building the strings); a quadratic scan of inputs this size takes minutes. #[test] fn scand_reads_long_formats_and_texts_in_linear_time() { // Scripts can pass anything; native code isn't bounded by the loop limit. @@ -1305,7 +1306,7 @@ mod scand { ); assert_eq!(o.error, None); assert_eq!(o.value, "true|true"); - assert!(started.elapsed() < std::time::Duration::from_secs(2), "{:?}", started.elapsed()); + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); } /// The check an XFA form runs on leaving a date field tries six formats with `util.scand` and From b6b67a86d834c74230563a1d309cc6a3826bfbb9 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 22 Oct 2026 15:08:56 +0800 Subject: [PATCH 049/517] fix(automation): guard MCP dispatch and bound line and file reads (#3924) --- crates/automation/src/a11y.rs | 10 +- crates/automation/src/comments.rs | 4 +- crates/automation/src/content.rs | 4 +- crates/automation/src/file_access.rs | 6 +- crates/automation/src/forms.rs | 2 +- crates/automation/src/lib.rs | 75 +++++++++++-- crates/automation/src/mcp.rs | 154 +++++++++++++++++++++++++-- crates/automation/src/signing.rs | 4 +- 8 files changed, 225 insertions(+), 34 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 2124549731..423e6d2515 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -607,9 +607,9 @@ impl Automation { for p in paths { let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); let result = - self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())).and_then(|bytes| { - pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true) - }); + self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then( + |bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true), + ); out.push(match result { Ok(r) => { let target = child(&folder, &name); @@ -696,7 +696,7 @@ impl Automation { let mut files = Vec::new(); for p in a.strs("paths")? { let path = self.resolve(p, false)?; - files.push((p.to_string(), std::fs::read(&path).map_err(|e| failed(format!("{p}: {e}")))?)); + files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?)); } let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?; let target = self.resolve(a.str("path")?, true)?; @@ -841,7 +841,7 @@ impl Automation { let result = self .resolve(p, false) .map_err(|e| e.to_string()) - .and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())) + .and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())) .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {})); out.push(match result { Ok(r) => { diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 3c53563bef..1266db0101 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -385,7 +385,7 @@ impl Automation { "attachment" => { let [x, y] = a.need::<2>("at", "an attachment (its icon's top-left)")?; let path = self.resolve(a.str("path")?, false)?; - let data = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let data = crate::read_input(&path)?; let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let icon = match a.opt_str("icon")? { Some(n) => pdfcraft_engine::AttachIcon::from_name(n) @@ -679,7 +679,7 @@ impl Automation { let page = self.page(a)?; let info = self.doc(a)?.info.pages[page].clone(); let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned())); let [x, y] = a.need::<2>("at", "a stamp (its centre)")?; diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 8926a1368b..a69f565171 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -118,7 +118,7 @@ impl Automation { let page = self.page(a)?; let ph = self.doc(a)?.info.pages[page].height as f64; let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let rect = rect_arg(a, "rect")?.map(|r| to_display(r, ph)); let mut out = self.apply(a, Edit::AddImage { page, rect, name, bytes: std::sync::Arc::new(bytes) })?; @@ -188,7 +188,7 @@ impl Automation { } if let Some(p) = a.opt_str("image")? { let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); // Box and transforms first, then the new picture. self.apply(a, Edit::UpdateContent { page, index, content: content.clone() })?; diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs index 55b10662f0..4820743e22 100644 --- a/crates/automation/src/file_access.rs +++ b/crates/automation/src/file_access.rs @@ -345,7 +345,8 @@ mod tests { // Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its // inputs and out_dir, walks only inside them (skipping links) and resolves every // discovered file again before `process` reads it or `publish` writes it. - const RESOLVED_BY_CALLER: &[&str] = &["write_atomic", "write_atomic_with", "read_review_sidecar", "process", "linked", "walk", "publish"]; + const RESOLVED_BY_CALLER: &[&str] = + &["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"]; let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut offenders = vec![]; for entry in std::fs::read_dir(&dir).unwrap() { @@ -365,7 +366,8 @@ mod tests { || !(trimmed.contains("std::fs") || trimmed.contains("File::open") || trimmed.contains("File::create") - || trimmed.contains("OpenOptions")) + || trimmed.contains("OpenOptions") + || trimmed.contains("read_input")) { continue; } diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 26e7b9d063..00e430b308 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -684,7 +684,7 @@ impl Automation { pub(crate) fn doc_import_data(&mut self, a: &Args) -> Result { let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let before = (self.doc(a)?.info.annotations.len(), self.doc(a)?.form.iter().filter(|f| !f.value.is_empty()).count()); let mut out = self.apply(a, Edit::ImportData { name, bytes: std::sync::Arc::new(bytes) })?; diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index c90240875b..f5c08ce108 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -394,7 +394,7 @@ impl Automation { "page_replace" => { let pages = self.pages(&a, "pages")?; let path = self.resolve(a.str("path")?, false)?; - let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?); + let bytes = Arc::new(read_input(&path)?); let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let src_pages = match a.opt_ints("from_pages")? { Some(p) => one_based(&p)?, @@ -543,7 +543,7 @@ impl Automation { "delete" => ImageEdit::Delete, "replace" => { let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; ImageEdit::Replace { name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes: Arc::new(bytes), @@ -766,7 +766,7 @@ impl Automation { "form_fill" => self.form_fill(&a)?, "form_set_image" => { let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })? } "form_reset" => self.form_reset(&a)?, @@ -1000,7 +1000,7 @@ impl Automation { fn doc_open(&mut self, a: &Args) -> Result { let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?; @@ -1270,7 +1270,7 @@ impl Automation { fn mark_file(&self, a: &Args) -> Result> { let Some(p) = a.opt_str("file")? else { return Ok(None) }; let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1; Ok(Some(pdfcraft_engine::MarkFile { name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), @@ -1444,7 +1444,7 @@ impl Automation { let mut images = Vec::new(); for p in a.strs("paths")? { let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes)); } let name = if images.len() == 1 { @@ -1459,7 +1459,7 @@ impl Automation { (Some(t), _) => ("Text".to_string(), t.to_string()), (None, Some(p)) => { let path = self.resolve(p, false)?; - let t = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let t = read_input(&path)?; (path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t)) } (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), @@ -1634,7 +1634,7 @@ impl Automation { fn insert_file(&mut self, a: &Args) -> Result { let at = self.position(a, "at")?; let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename }) @@ -1677,7 +1677,7 @@ impl Automation { // Each file is read, converted and copied in turn, so a long list holds one file at a time. let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result { let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?; let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); @@ -1694,7 +1694,7 @@ impl Automation { let mut out = Vec::new(); for &p in paths { let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| { - let bytes = std::fs::read(&src).map_err(|e| format!("{}: {e}", src.display()))?; + let bytes = read_input_bytes(&src).map_err(|e| format!("{}: {e}", src.display()))?; let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?; @@ -1798,7 +1798,7 @@ impl Automation { if let Some(g) = group { firsts.push((g, path.clone(), sources.len())); } - let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?); + let bytes = Arc::new(read_input(&path)?); // Images and text files are made into PDFs first (#569), as in the Combine files tab. let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); self.session.as_pdf(&file_name, &bytes).map_err(failed)? @@ -2700,6 +2700,28 @@ fn unused(dir: &Path, stem: &str) -> PathBuf { (2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first) } +/// Largest input file a tool reads into memory (512 MiB, matching `doc_batch`). +pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024; + +/// Read a resolved input file of at most [`MAX_INPUT`] bytes without buffering past the cap. +pub(crate) fn read_input_bytes(path: &Path) -> std::io::Result> { + use std::io::Read as _; + let file = std::fs::File::open(path)?; + if file.metadata().is_ok_and(|m| m.is_file() && m.len() > MAX_INPUT) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); + } + let mut bytes = Vec::new(); + file.take(MAX_INPUT + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_INPUT { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); + } + Ok(bytes) +} + +pub(crate) fn read_input(path: &Path) -> Result> { + read_input_bytes(path).map_err(|e| failed(format!("{}: {e}", path.display()))) +} + /// Write and sync a temporary file in the same directory, then rename over the target. /// This flushes staged contents; it does not promise parent-directory durability after a /// power loss. Filesystems that refuse sync cause the save to fail before publication. @@ -3257,4 +3279,35 @@ mod tests { assert!(a.session.get(id).is_none()); } } + + #[test] + fn tool_file_reads_refuse_inputs_exceeding_512_mib() { + let dir = staging_dir("capped-read"); + let huge = dir.join("huge.bin"); + let file = std::fs::File::create(&huge).unwrap(); + file.set_len(MAX_INPUT + 1).unwrap(); + drop(file); + let huge_str = huge.to_string_lossy().into_owned(); + + let mut a = Automation::new(); + let id = a.session.open("a.pdf", None, Arc::new(numbered_pages(2)), None).unwrap().0; + for (tool, args) in [ + ("doc_open", json!({ "path": huge_str })), + ("page_insert_file", json!({ "doc": id, "at": 1, "path": huge_str })), + ("page_replace", json!({ "doc": id, "pages": [1], "path": huge_str })), + ("page_add_image", json!({ "doc": id, "page": 1, "path": huge_str })), + ("doc_import_data", json!({ "doc": id, "path": huge_str })), + ("doc_create", json!({ "from": "text", "path": huge_str })), + ("doc_create", json!({ "from": "images", "paths": [huge_str] })), + ("doc_combine", json!({ "paths": [huge_str, huge_str] })), + ("form_merge_data", json!({ "paths": [huge_str], "path": dir.join("out.csv").to_string_lossy() })), + ("comment_add", json!({ "doc": id, "page": 1, "type": "attachment", "at": [72.0, 72.0], "path": huge_str })), + ("stamp_custom", json!({ "doc": id, "page": 1, "at": [72.0, 72.0], "path": huge_str })), + ("sign_trust", json!({ "paths": [huge_str] })), + ] { + let err = a.call(tool, &args).unwrap_err().to_string(); + assert!(err.contains("input exceeds 512 MiB"), "{tool}: {err}"); + } + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index f5fda5be97..84cc21b258 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -25,6 +25,10 @@ const PARSE_ERROR: i64 = -32700; const INVALID_REQUEST: i64 = -32600; const METHOD_NOT_FOUND: i64 = -32601; const INVALID_PARAMS: i64 = -32602; +const INTERNAL_ERROR: i64 = -32603; + +/// Longest JSON-RPC request line [`McpServer::serve`] buffers into memory (16 MiB). +pub const MAX_LINE_BYTES: usize = 16 * 1024 * 1024; const INSTRUCTIONS: &str = "PdfCraft edits PDFs. Open a file with doc_open to get a document id, then inspect \ (doc_info, text_extract, text_find, page_render) or edit it (page_*, doc_set_info). Edits are undoable \ @@ -83,18 +87,36 @@ impl McpServer { } /// Serve until `input` reaches end of file. - pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { - for line in input.lines() { - let line = line?; - if line.trim().is_empty() { - continue; + pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { + let mut buf = Vec::new(); + loop { + match read_line_limited(&mut input, &mut buf, MAX_LINE_BYTES)? { + LineRead::End => return Ok(()), + LineRead::TooLong => { + drain_line(&mut input)?; + buf.clear(); + buf.shrink_to(64 * 1024); + let reply = error(Value::Null, INVALID_REQUEST, &format!("request line exceeds {MAX_LINE_BYTES} bytes")); + writeln!(output, "{reply}")?; + output.flush()?; + continue; + } + LineRead::Line => {} } - if let Some(reply) = self.handle_line(&line) { + let reply = match std::str::from_utf8(&buf) { + Ok(line) if line.trim().is_empty() => None, + Ok(line) => self.handle_line(line), + Err(e) => Some(error(Value::Null, PARSE_ERROR, &format!("parse error: {e}")).to_string()), + }; + if buf.capacity() > 64 * 1024 { + buf.clear(); + buf.shrink_to(64 * 1024); + } + if let Some(reply) = reply { writeln!(output, "{reply}")?; output.flush()?; } } - Ok(()) } /// Handle one JSON-RPC message; returns the serialized reply, if one is due. @@ -116,7 +138,7 @@ impl McpServer { }; let params = obj.get("params").cloned().unwrap_or(Value::Null); let id = id?; // notifications/initialized, notifications/cancelled, …: nothing to answer - let result = if method == "tools/call" { guarded_tool(|| self.dispatch(method, ¶ms)) } else { self.dispatch(method, ¶ms) }; + let result = guarded_dispatch(method, || self.dispatch(method, ¶ms)); Some(match result { Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), Err((code, message)) => error(id, code, &message), @@ -323,11 +345,67 @@ fn unwrap_tool_call(args: &Value) -> Result<(String, Value), String> { Ok((name.to_owned(), inner)) } +/// What [`read_line_limited`] found. +#[derive(Debug, PartialEq, Eq)] +enum LineRead { + /// A line, now in the buffer (newline and a trailing `\r` stripped). + Line, + /// End of stream. + End, + /// The line is longer than the limit; at most `max + 1` bytes of it were read. + TooLong, +} + +/// Read one `\n`-terminated line of at most `max` bytes into `buf`. +fn read_line_limited(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> std::io::Result { + use std::io::Read as _; + buf.clear(); + // `take` bounds what is buffered for one line: never more than `max + 1` bytes. + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + if reader.take(limit).read_until(b'\n', buf)? == 0 { + return Ok(LineRead::End); + } + if buf.last() == Some(&b'\n') { + buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + return Ok(LineRead::Line); + } + // No newline: either the limit was passed, or this is a last line before end of stream. + Ok(if buf.len() > max { LineRead::TooLong } else { LineRead::Line }) +} + +/// Discard the rest of an overlong line up to `\n` or end of stream without buffering it. +fn drain_line(reader: &mut impl BufRead) -> std::io::Result<()> { + loop { + let chunk = reader.fill_buf()?; + if chunk.is_empty() { + return Ok(()); + } + if let Some(pos) = chunk.iter().position(|&b| b == b'\n') { + reader.consume(pos + 1); + return Ok(()); + } + let len = chunk.len(); + reader.consume(len); + } +} + /// Last-resort boundary shared by every tools/call route, including compact dispatch. fn guarded_tool(f: impl FnOnce() -> Result) -> Result { pdfcraft_engine::guard(f).unwrap_or_else(|message| Ok(tool_error(&format!("internal error: {message}")))) } +/// Last-resort boundary for every JSON-RPC method: tool calls report panics in the tool result +/// (`isError: true`), while resource and protocol methods report a JSON-RPC internal error. +fn guarded_dispatch(method: &str, f: impl FnOnce() -> Result) -> Result { + if method == "tools/call" { + return guarded_tool(f); + } + pdfcraft_engine::guard(f).unwrap_or_else(|message| Err((INTERNAL_ERROR, format!("internal error: {message}")))) +} + fn tool_content(name: &str, content: Vec) -> Value { let failed = matches!(name, "command_batch" | "doc_batch") && content.iter().any(|c| match c { @@ -509,7 +587,12 @@ fn call_result(content: Vec) -> Value { #[cfg(test)] mod tests { - use super::{Resource, first_sentence, parse_uri}; + use std::io::{BufReader, Read as _}; + + use serde_json::{Value, json}; + + use super::{McpServer, Resource, first_sentence, parse_uri}; + use crate::Automation; #[test] fn conventions_panic_is_a_tool_error() { @@ -520,6 +603,59 @@ mod tests { assert_eq!(next["ok"], true); } + #[test] + fn guarded_dispatch_catches_resource_and_protocol_panics() { + for method in ["resources/read", "resources/list", "resources/templates/list", "initialize"] { + let (code, msg) = super::guarded_dispatch(method, || panic!("synthetic {method} failure")).unwrap_err(); + assert_eq!(code, -32603, "{method}"); + assert!(msg.contains(&format!("synthetic {method} failure")), "{msg}"); + } + let ok = super::guarded_dispatch("resources/list", || Ok(json!({"resources": []}))).unwrap(); + assert_eq!(ok["resources"], json!([])); + } + + #[test] + fn read_line_limited_and_drain_line_respect_boundaries() { + use super::{LineRead, drain_line, read_line_limited}; + let mut buf = Vec::new(); + let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc"; + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"bb", "CRLF is stripped"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"cc", "a last line without a newline is still read"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::End); + + let mut overlong: &[u8] = b"0123456789abcdef\nafter\n"; + assert_eq!(read_line_limited(&mut overlong, &mut buf, 10).unwrap(), LineRead::TooLong); + assert_eq!(buf.len(), 11, "at most max + 1 bytes are buffered"); + drain_line(&mut overlong).unwrap(); + assert_eq!(read_line_limited(&mut overlong, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"after", "the next line after an overlong line is read cleanly"); + } + + #[test] + fn serve_answers_non_utf8_and_overlong_lines_and_keeps_serving() { + let init = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "0" } } + }) + .to_string(); + let tail = format!("\n{init}\n"); + let stream = (&b"\xff\n"[..]).chain(std::io::repeat(b'a').take(64 * 1024 * 1024)).chain(tail.as_bytes()); + let mut out = Vec::new(); + McpServer::new(Automation::new()).serve(BufReader::new(stream), &mut out).unwrap(); + let replies: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(replies.len(), 3, "{replies:?}"); + assert_eq!(replies[0]["error"]["code"], -32700); + assert_eq!(replies[1]["error"]["code"], -32600); + assert_eq!(replies[2]["id"], 1); + assert_eq!(replies[2]["result"]["serverInfo"]["name"], "pdfcraft"); + } + #[test] fn first_sentence_skips_abbreviations() { assert_eq!(first_sentence("Rotate pages. Undoable."), "Rotate pages."); diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index c7a043c890..743a36ff56 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -189,7 +189,7 @@ impl Automation { return Err(failed(format!("windows:{r}: Windows certificate store identities are only available on Windows"))); } let path = self.resolve(a.str("id")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(|e| match e { sign::SignError::WrongPassword => bad("the digital ID password is incorrect"), e => failed(e), @@ -320,7 +320,7 @@ impl Automation { }; for p in paths { let path = self.resolve(&p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let lower = p.to_ascii_lowercase(); let found = if lower.ends_with(".p12") || lower.ends_with(".pfx") { let id = sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(failed)?; From 4d22d2b74ae2b5ea72f565808c1b29608db30421 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:17:40 -0400 Subject: [PATCH 050/517] M12: doc comments on the picture-alternatives tests --- crates/engine/src/tests.rs | 2 ++ crates/xfa/src/tests.rs | 3 +++ 2 files changed, 5 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 95ace8c2d2..d8a680880d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7101,6 +7101,8 @@ fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() { assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}"); } +/// A date typed in another of an XFA date field's edit patterns is kept in its first: yymmdd and +/// yyyymd are rewritten as yyyymmdd, a date none of them reads is refused, and the data holds the date. #[test] fn a_date_typed_by_another_of_an_xfa_fields_edit_patterns_is_kept_in_its_first() { let mut s = Session::new(); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index dbae4ef911..87875211fe 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2658,6 +2658,9 @@ fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() { assert!((left_edge("right", "TITLE ") - (36.0 + w - ink)).abs() < 1e-6); } +/// `typed_date` rewrites a date typed by another of a field's edit-picture patterns in the first +/// one, and leaves alone a date the first pattern reads, text no pattern reads, a field with one +/// pattern and a field that isn't a date. #[test] fn a_date_typed_by_another_of_its_edit_pictures_patterns_is_written_in_the_first() { let (tpl, _) = crate::parse(&crate::fixtures::date_alternatives_template()).unwrap(); From a204078cbbe87993c9460ab228f5a31b210414c1 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Thu, 22 Oct 2026 11:51:48 +0200 Subject: [PATCH 051/517] M4: Extract keeps the bookmarks of the pages it copies (#4020) page_extract (and Split, which extracts each part) wrote the copied pages without any outline, even when every page was extracted, though the tool says bookmarks come along. extract_pages now reads the source's bookmarks with the destinations mapped to the copies, as Combine already does, and writes them as the new document's outline. A bookmark whose page was left out goes, unless a bookmark under it stays. A source without bookmarks still gets no outline. Fixes #4020 --- crates/organize/src/import.rs | 28 +++++++++++++++++++++++++++- crates/organize/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 80f90fb2a4..4f3b1ffd30 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -844,12 +844,26 @@ fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), Organize /// A new document containing copies of `pages` from `src` (Extract Pages / Split). /// Document information (title, author…) is carried over, and so is the print standard the -/// source declares (PDF/X: its output intents and identification, #263). +/// source declares (PDF/X: its output intents and identification, #263), and the bookmarks that +/// go to the copied pages. pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { let mut out = Document::new_empty(); // One source: nothing to deduplicate. Its fields keep their names (a source with two // same-named field roots stays one field, as it was), so Extract and Split never rename. let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?; + // The bookmarks of these pages come along, mapped to the copies (#4020). + let outline = kept_bookmarks(source_outline(src, imported.page_map)); + if !outline.is_empty() { + let root = crate::outline::container(&mut out, &[])?; + if let Some((first, last, _)) = write_outline_level(&mut out, root, &outline)? { + out.update_dict(root, |d| { + d.set(b"First".to_vec(), Object::Ref(first)); + d.set(b"Last".to_vec(), Object::Ref(last)); + })?; + let top = crate::outline::children(&out, root); + crate::outline::relink_and_recount(&mut out, root, &top)?; + } + } for key in crate::INFO_KEYS { if let Some(v) = crate::info(src, key) { crate::set_info(&mut out, key, &v)?; @@ -1221,6 +1235,18 @@ fn source_outline(src: &Document, pages: HashMap) -> Vec) -> Vec { + nodes + .into_iter() + .filter_map(|mut n| { + n.children = kept_bookmarks(n.children); + (n.dest.is_some() || !n.children.is_empty()).then_some(n) + }) + .collect() +} + /// One level of bookmarks from `first` (its siblings), each with its children. Cycle- and size-safe: /// at most `budget` bookmarks are read in all, and nesting stops at 32 levels. fn outline_level(m: &Copier<'_>, first: Option, depth: u8, budget: &mut usize) -> Vec { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 7a80e38e4c..f79a4e95f2 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -981,6 +981,28 @@ fn form_fields_come_along_and_are_registered() { assert_eq!(field.reference(b"P"), Some(pages(&out).unwrap()[0].obj)); } +/// #4020: Extract keeps the bookmarks that go to the pages it copies, mapped to the copies. One whose +/// page was left out goes, unless a bookmark under it stays. +#[test] +fn extract_keeps_the_bookmarks_of_the_pages_it_copies() { + let mut src = doc_a(); + crate::add_bookmark(&mut src, &[], 0, "One", 0).unwrap(); + crate::add_bookmark(&mut src, &[], 1, "Two", 1).unwrap(); + crate::add_bookmark(&mut src, &[1], 0, "Two.a", 2).unwrap(); + crate::add_bookmark(&mut src, &[], 2, "Three", 2).unwrap(); + let marks = |d: &Document| -> Vec<(String, usize, Option)> { bookmarks(d).into_iter().map(|b| (b.title, b.path.len(), b.page)).collect() }; + let s = |t: &str| t.to_string(); + + let all = full_roundtrip(&extract_pages(&src, &[0, 1, 2]).unwrap()); + assert_eq!(marks(&all), [(s("One"), 1, Some(0)), (s("Two"), 1, Some(1)), (s("Two.a"), 2, Some(2)), (s("Three"), 1, Some(2))]); + + let last = full_roundtrip(&extract_pages(&src, &[2]).unwrap()); + assert_eq!(marks(&last), [(s("Two"), 1, None), (s("Two.a"), 2, Some(0)), (s("Three"), 1, Some(0))], "One's page was left out"); + + let none = full_roundtrip(&extract_pages(&doc_a(), &[0]).unwrap()); + assert!(!none.get(none.root().unwrap()).as_dict().unwrap().contains(b"Outlines"), "no bookmarks, no outline"); +} + #[test] fn popup_cycles_copy_once_and_structure_links_are_removed() { let out = full_roundtrip(&extract_pages(&doc_a(), &[2]).unwrap()); From bd00a131b67f37ca8b9eb2c5e0f078a6639e7bac Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Thu, 22 Oct 2026 12:52:40 +0200 Subject: [PATCH 052/517] M6: form_fill warns when a value does not fit its field (#4018) With a fixed font size, a text field's appearance cuts a long value at the edge of its box, but form_fill only answered "filled": n. A person sees the cut; an agent filling forms through the CLI or MCP did not. The text appearance builder now notes when the text it lays out does not fit: one line wider than the box, or wrapped lines below it. forms::appearance::value_overflow asks that for a field's widget, Document::field_overflow for a field by name, and form_fill adds a warning for each filled field that is cut off. The value is still saved whole and drawn as before; auto-sized fields shrink to fit and are not reported. The automation tests' inline PDF builder moves to a shared pdf_of. Fixes #4018 --- crates/automation/src/forms.rs | 17 ++++++++ crates/automation/tests/automation.rs | 59 +++++++++++++++++++++------ crates/engine/src/lib.rs | 8 ++++ crates/forms/src/appearance.rs | 54 ++++++++++++++++++++++-- crates/forms/src/tests.rs | 37 +++++++++++++++++ 5 files changed, 159 insertions(+), 16 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 26e7b9d063..763fd0efd7 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -288,6 +288,23 @@ impl Automation { let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Fill in form".into(), edits } }; let mut out = self.apply(a, edit)?; out["filled"] = json!(values.len()); + // A value the box cuts off at a fixed font size is saved whole but shows only in part: + // said, not silent (#4018). + let doc = self.doc(a)?; + let cut: Vec = values + .keys() + .filter_map(|name| doc.field_overflow(name).map(|(page, o)| (name, page, o))) + .map(|(name, page, o)| { + let page = page.map(|p| format!(" (page {})", p.saturating_add(1))).unwrap_or_default(); + json!(format!("form field {name:?}{page}: {o}, so the box shows only part of it (the whole value is saved)")) + }) + .collect(); + if !cut.is_empty() { + match out.get_mut("warnings").and_then(Value::as_array_mut) { + Some(w) => w.extend(cut), + None => out["warnings"] = Value::Array(cut), + } + } Ok(out) } diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index bf6169f427..9cf0b768c6 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2293,6 +2293,51 @@ fn forms_through_tools() { assert_ne!(v, "Filled by an agent"); } +/// A PDF of the given objects (numbered from 1). +fn pdf_of(objs: &[&str]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #4018: `form_fill` warns about a value its box cuts off at the field's fixed font size (the +/// value is saved whole), and not about one that fits. +#[test] +fn form_fill_warns_when_a_value_does_not_fit() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>", + "<< /Fields [5 0 R 6 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (narrow) /Rect [10 250 150 268] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (wide) /Rect [10 200 310 218] /P 3 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + let dir = workdir("form-overflow"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let long = "Doe, Jane (represented by John Doe)"; + let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "narrow": long, "wide": long } })); + let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + assert_eq!(warnings.len(), 1, "{r}"); + assert!(warnings[0].starts_with("form field \"narrow\" (page 1): the value does not fit"), "{}", warnings[0]); + let after = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert_eq!(after["fields"][0]["value"], long, "saved whole"); + let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "narrow": "Ada" } })); + assert!(r.get("warnings").is_none(), "{r}"); +} + /// A form whose fields are only page widgets (the `/Fields` list is empty) still lists, fills and /// resets through the tools, and the leniency shows up as a repair note. #[test] @@ -2308,19 +2353,7 @@ fn orphan_form_fields_through_tools() { "<< /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 150 90 170] /P 3 0 R >>", // 6 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", // 7 ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); - out + pdf_of(&objs) } let dir = workdir("orphan-forms"); std::fs::write(dir.join("orphan.pdf"), orphan_form()).unwrap(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e20cbc596e..7255cfec0b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -843,6 +843,14 @@ impl Document { matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f)) } + /// Whether text field `name`'s value is cut off by its box at the field's font size (#4018): + /// the page (0-based) of the first widget that cuts it, and how. + pub fn field_overflow(&self, name: &str) -> Option<(Option, pdfcraft_forms::appearance::Overflow)> { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o))) + } + /// Remove Hidden Information: what each category would remove. pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 3b6f6f20d6..651f8e3208 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -449,6 +449,40 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri /// hold it: the form's font is a simple (WinAnsi) font, Helvetica stands in for one PdfCraft can't /// write with (an embedded subset, `Identity-H`), or a UCS-2 CMap meets a character beyond U+FFFF. pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option) { + let (stream, unencodable, _) = field_appearance_fit(doc, f, w, values, format); + (stream, unencodable) +} + +/// A text value that its box cuts off at the field's font size (#4018). +#[derive(Clone, Debug, PartialEq)] +pub enum Overflow { + /// One line `width` points wide, with `room` for it, at `size` points. + Wide { width: f64, room: f64, size: f64 }, + /// `lines` wrapped lines, of which the box shows `room`, at `size` points. + Tall { lines: usize, room: usize, size: f64 }, +} + +impl std::fmt::Display for Overflow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Wide { width, room, size } => { + write!(f, "the value does not fit: {} pt wide at {} pt, room for {} pt", fmt(*width), fmt(*size), fmt(*room)) + } + Self::Tall { lines, room, size } => write!(f, "the value does not fit: {lines} lines at {} pt, room for {room}", fmt(*size)), + } + } +} + +/// Whether the text field's value, drawn in widget `w`, is cut off by its box. +pub fn value_overflow(doc: &Document, f: &Field, w: &Widget) -> Option { + if f.kind != FieldKind::Text { + return None; + } + field_appearance_fit(doc, f, w, &f.value, true).2 +} + +/// [`field_appearance_drawn`], and whether the text drawn is cut off by the box. +fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option, Option) { // The Format event: what is shown, not what is stored. let formatted: Vec; let values = if format @@ -513,6 +547,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S } }; let mut size = da.size; + let mut overflow = None; match f.kind { FieldKind::List => { // Every option, one per line from the top (from `/TI`); selected ones highlighted. @@ -539,13 +574,22 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of); } let mut y = height - pad - size * 0.85; - for line in wrap_with(&text, size, inner_w, width_of) { + let lines = wrap_with(&text, size, inner_w, width_of); + // The lines whose descent stays inside the border; the rest are cut off. + let mut room = 0; + for line in &lines { if y < -size { break; } - show(&mut body, x_for(&line, size), y, &line); + if y - 0.207 * size >= bw - 0.01 { + room += 1; + } + show(&mut body, x_for(line, size), y, line); y -= size * 1.15; } + if room < lines.len() { + overflow = Some(Overflow::Tall { lines: lines.len(), room, size }); + } } else { if size == 0.0 { // Comb cells hold one character each, so only the height limits them. @@ -565,6 +609,10 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S show(&mut body, cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s); } } else { + let tw = width_of(&text.replace(['\n', '\r'], " "), size); + if tw > inner_w + 0.01 { + overflow = Some(Overflow::Wide { width: tw, room: inner_w, size }); + } show(&mut body, x_for(&text, size), y, &text); } } @@ -595,7 +643,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S d.set(b"Subtype".to_vec(), Object::name("Form")); set_form_box(&mut d, place); d.set(b"Resources".to_vec(), Object::Dict(res)); - (Stream::flate(d, &content), unencodable) + (Stream::flate(d, &content), unencodable, overflow) } /// On/Off appearances for a check box or radio button that has none (a check mark or a dot, diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b2e4994ecf..84ce879847 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2981,3 +2981,40 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() { assert_eq!(walk.next(), None); assert_eq!(page_refs(&doc), [page]); } + +/// #4018: a value that doesn't fit its box at the field's font size is reported (it is drawn cut +/// at the box). An auto-sized field shrinks to fit, and a short or empty value fits. +#[test] +fn a_value_wider_or_taller_than_its_box_is_reported() { + let long = "Doe, Jane (represented by John Doe)"; + let field = |name: &str, rect: &str, extra: &str, value: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) /Rect [{rect}] /P 3 0 R {extra} >>") + }; + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + field("single", "50 700 190 718", "", long), + field("multi", "50 650 190 668", "/Ff 4096", long), + field("auto", "50 600 190 618", "/DA (/Helv 0 Tf 0 g)", long), + field("wide", "50 550 350 568", "", long), + field("short", "50 500 190 518", "/Ff 4096", "Ada"), + ]); + let overflow = |name: &str| { + let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap(); + appearance::value_overflow(&doc, &f, &f.widgets[0]) + }; + match overflow("single") { + Some(appearance::Overflow::Wide { width, room, size }) => { + assert_eq!(size, 12.0); + assert!((width - 201.4).abs() < 1.0 && room < 140.0, "{width} in {room}"); + } + other => panic!("single: {other:?}"), + } + assert_eq!(overflow("multi"), Some(appearance::Overflow::Tall { lines: 2, room: 1, size: 12.0 })); + assert_eq!(overflow("auto"), None, "auto size shrinks to fit"); + assert_eq!(overflow("wide"), None); + assert_eq!(overflow("short"), None); +} From 356e6cefe04d6919cc948e462464d392052c224d Mon Sep 17 00:00:00 2001 From: Adrien Alberti Date: Thu, 22 Oct 2026 11:52:49 +0200 Subject: [PATCH 053/517] M12: type or pick an architect's 1:N drawing scale in the Measure panel, applied at once On top of #3011's page unit, the Drawing scale block gets a "1:N" field (1:50, 1/100, 50, 1:2,5 are read; zero, negative, huge and malformed values are refused with a message) and a combo of common architect scales (1:1 to 1:1000). Either one fills the existing fields (1 mm of paper = N mm, shown in metres) and applies the scale to the page without a second click, only when the document allows annotation. Apply scale is unchanged. --- crates/ui-egui/src/measure_ui.rs | 142 ++++++++++++++++++++++++++++++- crates/ui-egui/tests/control.rs | 52 +++++++++++ parity/acrobat-features.toml | 2 +- 3 files changed, 193 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index f91e4bf3f0..d80ecca3e2 100644 --- a/crates/ui-egui/src/measure_ui.rs +++ b/crates/ui-egui/src/measure_ui.rs @@ -55,6 +55,10 @@ pub struct MeasureView { pub unit: String, /// The unit picked for the page side of the scale; `None` follows `unit`. pub page_unit: Option, + /// The "1:N" field of the drawing scale, as typed. + pub ratio_text: String, + /// The "1:N" field was edited and not yet applied. + ratio_dirty: bool, pub precision: u8, pub name: String, pub whole_page: bool, @@ -83,6 +87,8 @@ impl Default for MeasureView { real_distance: 1.0, unit: "in".into(), page_unit: None, + ratio_text: String::new(), + ratio_dirty: false, precision: 2, name: "Drawing scale".into(), whole_page: true, @@ -99,6 +105,35 @@ impl Default for MeasureView { pub(crate) fn page_unit_of(picked: Option, unit: &str, display: crate::units::Unit) -> PageUnit { picked.or_else(|| PageUnit::from_label(unit)).or_else(|| PageUnit::from_label(display.label())).unwrap_or(PageUnit::Pt) } +/// Drawing scales an architect's rule offers, as the N of 1:N. +pub(crate) const RATIOS: [f64; 10] = [1.0, 5.0, 10.0, 20.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0]; +/// "1:200", "1/200", "1 : 200" or a bare "200" (a comma works as the decimal mark), as the N of 1:N. +/// `None` for anything else, for zero, negative or absurd values. +pub(crate) fn parse_ratio(text: &str) -> Option { + let text = text.trim().replace(',', "."); + let n = match text.split_once([':', '/']) { + Some((one, n)) => n.trim().parse::().ok()? / one.trim().parse::().ok()?, + None => text.parse::().ok()?, + }; + (n.is_finite() && (1e-6..=1e9).contains(&n)).then_some(n) +} +/// "1:200", or "1:2.5" for a ratio that isn't a whole number. +pub(crate) fn format_ratio(n: f64) -> String { + let rounded = (n * 100.0).round() / 100.0; + if (rounded - rounded.round()).abs() < 1e-9 { format!("1:{}", rounded.round()) } else { format!("1:{rounded}") } +} +/// Fill the scale fields for 1:N: 1 mm on the page is N mm, shown in metres. Returns `false`, changing nothing, when `n` is not a usable ratio. +pub(crate) fn set_ratio(state: &mut MeasureView, n: f64) -> bool { + if !n.is_finite() || n <= 0.0 { + return false; + } + state.unit = "m".into(); + state.page_unit = Some(PageUnit::Mm); + state.drawing_points = 1.0; + state.real_distance = n / 1000.0; + state.ratio_text = format_ratio(n); + true +} impl MeasureView { pub fn cancel(&mut self) { self.points.clear(); @@ -363,7 +398,27 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.add(egui::Slider::new(&mut state.sensitivity, 1.0..=20.0).text(tl!("Sensitivity"))); } ui.separator(); - ui.label(tl!("Drawing scale")); + // "1:N" fills the fields below and applies at once, as does picking a common scale. + let mut chosen = None; + ui.horizontal(|ui| { + ui.label(tl!("Drawing scale")); + let typed = ui.add(crate::widgets::line(&mut state.ratio_text).hint_text("1:100").desired_width(56.0).char_limit(16)); + typed.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tl!("Scale"))); + state.ratio_dirty |= typed.changed(); + if typed.lost_focus() && std::mem::take(&mut state.ratio_dirty) { + match parse_ratio(&state.ratio_text) { + Some(n) => chosen = Some(n), + None => state.error = Some(tl!("Enter a scale such as 1:50.").into()), + } + } + egui::ComboBox::from_id_salt("measure-common-scales").selected_text(tl!("Common scales")).width(20.0).show_ui(ui, |ui| { + for n in RATIOS { + if ui.selectable_label(state.ratio_text == format_ratio(n), format_ratio(n)).clicked() { + chosen = Some(n); + } + } + }); + }); ui.horizontal(|ui| { ui.add(egui::DragValue::new(&mut state.drawing_points).range(0.000001..=1e9)); let page_unit = page_unit_of(state.page_unit, &state.unit, units); @@ -427,7 +482,16 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { } } let export = ui.button(tl!("Export measurements as CSV")).clicked(); - if apply && let Some(info) = info { + if let Some(n) = chosen + && set_ratio(state, n) + { + state.error = None; + } + // Apply scale, or a 1:N typed or picked: the fields are applied to the document at once. + if (apply || chosen.is_some()) + && doc.allows_annotation() + && let Some(info) = info + { let result = doc.measurement_to_user(page, [0.0, 0.0]).and_then(|a| doc.measurement_to_user(page, [1.0, 0.0]).map(|b| (a, b))).and_then(|(a, b)| { let page_unit = page_unit_of(state.page_unit, &state.unit, units); @@ -513,3 +577,77 @@ fn export_csv(app: &mut PdfCraftApp) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// What Apply scale builds from the panel's fields, then a distance read with it. + fn measured(state: &MeasureView, from: Point, to: Point) -> f64 { + let page_unit = page_unit_of(state.page_unit, &state.unit, crate::units::Unit::Points); + let scale = Scale::ratio([0.0, 0.0], [1.0, 0.0], state.drawing_points, page_unit, state.real_distance, &state.unit, 4).expect("scale"); + measure::reading(Kind::Distance, &[from, to], &scale).expect("reading").value + } + + #[test] + fn typed_ratios_are_read_leniently() { + assert_eq!(parse_ratio("1:200"), Some(200.0)); + assert_eq!(parse_ratio(" 1 : 50 "), Some(50.0)); + assert_eq!(parse_ratio("1/20"), Some(20.0)); + assert_eq!(parse_ratio("250"), Some(250.0)); + assert_eq!(parse_ratio("1:2,5"), Some(2.5)); + assert_eq!(parse_ratio("2:1"), Some(0.5)); + } + + #[test] + fn invalid_ratios_are_refused() { + for bad in ["", " ", ":", "1:", ":50", "1:0", "0", "-5", "1:-5", "0:5", "abc", "1:abc", "1:50:2", "1:inf", "NaN", "1e12", "1:1e12"] { + assert_eq!(parse_ratio(bad), None, "{bad:?}"); + } + } + + #[test] + fn ratios_are_written_as_people_say_them() { + assert_eq!(format_ratio(50.0), "1:50"); + assert_eq!(format_ratio(2.5), "1:2.5"); + assert_eq!(format_ratio(0.5), "1:0.5"); + for n in RATIOS { + assert_eq!(parse_ratio(&format_ratio(n)), Some(n)); + } + } + + #[test] + fn every_offered_ratio_reads_paper_length_times_n_in_metres_whatever_the_unit_was() { + for unit in ["mm", "cm", "m", "km", "in", "ft"] { + for n in RATIOS { + let mut state = MeasureView { unit: unit.into(), ..Default::default() }; + assert!(set_ratio(&mut state, n)); + assert_eq!(state.unit, "m"); + // 72 pt is one inch of paper: 25.4 mm x N, shown in metres. + let v = measured(&state, [0.0, 0.0], [0.0, 72.0]); + let expected = 25.4 * n / 1000.0; + assert!((v - expected).abs() < 1e-9 * expected.max(1.0), "1:{n} in {unit} gave {v}, wanted {expected}"); + } + } + } + + #[test] + fn any_starting_unit_becomes_metres() { + let mut state = MeasureView { unit: "yards?".into(), ..Default::default() }; + assert!(set_ratio(&mut state, 50.0)); + assert_eq!(state.unit, "m"); + // 1 mm of paper at 1:50 is 50 mm. + let v = measured(&state, [0.0, 0.0], [72.0 / 25.4, 0.0]); + assert!((v - 0.05).abs() < 1e-9, "{v}"); + } + + #[test] + fn an_unusable_ratio_changes_nothing() { + let mut state = MeasureView::default(); + let before = (state.drawing_points, state.real_distance, state.unit.clone(), state.page_unit); + for n in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert!(!set_ratio(&mut state, n)); + } + assert_eq!((state.drawing_points, state.real_distance, state.unit.clone(), state.page_unit), before); + } +} diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index 9082c689c3..516d85ed67 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -1362,6 +1362,58 @@ fn a_1_to_50_scale_in_millimetres_measures_a_10_mm_line_as_500_mm() { assert_eq!(measurements[0].reading.unit, "mm"); } +#[test] +fn a_plan_scale_picked_or_typed_applies_at_once() { + use pdfcraft_engine::measure::{self, Kind}; + let (mut h, c) = harness_pages(1); + let doc = h.state().views[0].id; + ok(&mut h, &c, "ui.command", json!({"id":"measure.scale"})); + h.run_steps(3); + h.state_mut().views[0].measure.unit = "m".into(); + h.run_steps(2); + // 60 mm of paper. + let line = [[20.0, 20.0], [20.0 + 60.0 / 25.4 * 72.0, 20.0]]; + let read = |h: &Harness<'static, PdfCraftApp>| { + let scale = h.state().session.get(doc).unwrap().measurement_scale(0, line[0]).unwrap(); + (measure::reading(Kind::Distance, &line, &scale).unwrap().label, scale.ratio) + }; + // Picked from the common scales: no Apply click. + h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().is_some_and(|v| v == "Common scales")).click(); + h.run_steps(2); + h.get_by_label("1:200").click_accesskit(); + h.run_steps(3); + assert_eq!(read(&h), ("12.00 m".to_string(), "1 mm = 0.2 m".to_string())); + // Typed, in the other notations; leaving the field applies. + fn field<'a>(h: &'a Harness<'static, PdfCraftApp>) -> egui_kittest::Node<'a> { + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Scale") + } + for (typed, label) in [("1/50", "3.00 m"), ("1:100", "6.00 m")] { + field(&h).click(); + h.run_steps(1); + h.state_mut().views[0].measure.ratio_text.clear(); + field(&h).type_text(typed); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(read(&h).0, label, "{typed}"); + } + // Nonsense is refused with a message and leaves the page scale alone. + let before = read(&h); + field(&h).click(); + h.run_steps(1); + h.state_mut().views[0].measure.ratio_text.clear(); + field(&h).type_text("1:0"); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(read(&h), before); + assert!(h.state().views[0].measure.error.is_some()); + // The scale is one undoable edit. + ok(&mut h, &c, "ui.command", json!({"id":"edit.undo"})); + h.run_steps(2); + assert_ne!(read(&h), before); +} + #[test] fn docking_pointer_group_float_close_restore_and_document_commands() { use pdfcraft_ui_egui::docking::Panel; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c06f26e24d..423d1814b4 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4256,7 +4256,7 @@ milestone = "M12" status = "partial" commands = ["measure.distance"] tools = ["measure_distance"] -evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] +evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export", "crates/ui-egui/tests/control.rs::a_plan_scale_picked_or_typed_applies_at_once", "crates/ui-egui/src/measure_ui.rs::typed_ratios_are_read_leniently"] notes = "Two-point distance annotations with saved numeric captions and rectilinear scales. Partial: only single-unit decimal formats; compound (ft-in) and fractional formats are not supported (listed as unsupported, data kept), no leader lines or caption placement options, geospatial scales unsupported, and interop with Acrobat-authored measurements (reading theirs, Acrobat reading ours) is untested." [[feature]] From 9dab857ca3babe685e7187f63ceac0f01da2feb8 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:48:21 -0400 Subject: [PATCH 054/517] M12: only date clauses are other ways to type a date, and a date is rewritten only into a first pattern that keeps it --- crates/xfa/src/fixtures.rs | 5 ++++- crates/xfa/src/picture.rs | 23 +++++++++++++++++++---- crates/xfa/src/tests.rs | 13 +++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 9e76fbbaea..9b4988aef3 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -506,7 +506,8 @@ pub fn fields_template(data: &str) -> String { } /// Date fields whose edit pictures list alternatives, as forms let a date be typed several ways -/// (`date{YYYYMMDD}|date{YYMMDD}|…`); one with a single pattern; a text field. +/// (`date{YYYYMMDD}|date{YYMMDD}|…`); one with a single pattern; a text field; `code`, whose +/// only alternative is a text clause; and `short`, whose first pattern has a two-digit year. pub fn date_alternatives_template() -> String { r##" @@ -516,6 +517,8 @@ pub fn date_alternatives_template() -> String { date{YYYYMMDD}|date{DDMMMYY}|date{YYMMDD}|date{YYYYMMD}|date{YY MM DD} date{YYYY-MM-DD} + date{YYYYMMDD}|text{YYMMDD} + date{YYMMDD}|date{YYYYMMDD} diff --git a/crates/xfa/src/picture.rs b/crates/xfa/src/picture.rs index 58bd48cd69..2da57a2b0d 100644 --- a/crates/xfa/src/picture.rs +++ b/crates/xfa/src/picture.rs @@ -29,8 +29,9 @@ pub(crate) fn alternatives(picture: &str) -> Vec<&str> { /// `text` typed into the date field at `som` by one of its edit picture's alternatives after the /// first, written in the first, which the field shows and checks typed dates against (`261018` /// becomes `20261018` for `date{YYYYMMDD}|date{YYMMDD}`). A pattern reads the text only when the -/// date it gives, written in that pattern, is the text again. `None` when the field isn't a date -/// field with alternatives, the first pattern reads the text, or no other does. +/// date it gives, written in that pattern, is the text again, and only date clauses count. `None` +/// when the field isn't a date field with alternatives, the first pattern reads the text, no +/// other does, or the first pattern can't hold the date (a two-digit year for 1901). pub fn typed_date(tpl: &Template, som: &str, text: &str) -> Option { let f = template_field(tpl, som)?; if f.ui != Ui::DateTimeEdit { @@ -42,6 +43,20 @@ pub fn typed_date(tpl: &Template, som: &str, text: &str) -> Option { if reads(&first).is_some() { return None; } - let iso = alternatives(picture).into_iter().skip(1).filter_map(date_letters).find_map(|p| reads(&p))?; - Some(iso_to_pattern(&iso, &first)) + let iso = alternatives(picture).into_iter().skip(1).filter(|a| is_date_clause(a)).filter_map(date_letters).find_map(|p| reads(&p))?; + // Written in the first pattern only when it reads back as the same date: a year a two-digit + // year can't hold would otherwise become another century's. + let shown = iso_to_pattern(&iso, &first); + (pattern_to_iso(&shown, &first).as_deref() == Some(iso.as_str())).then_some(shown) +} + +/// Whether picture clause `clause` is a date pattern: a `date{…}` clause (with or without a +/// locale, `date(en_US){…}`), or a bare pattern, which a date field reads as a date. A clause of +/// another category (`text{…}`, `num{…}`, `time{…}`) is not, whatever its letters. +fn is_date_clause(clause: &str) -> bool { + let clause = clause.trim(); + match clause.find(['{', '(']) { + Some(i) => clause.get(..i).is_some_and(|category| category.trim().eq_ignore_ascii_case("date")), + None => true, + } } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 87875211fe..da9c8832ec 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2675,3 +2675,16 @@ fn a_date_typed_by_another_of_its_edit_pictures_patterns_is_written_in_the_first assert_eq!(typed("form[0].born[0]", "181026"), None, "one pattern: nothing else to try"); assert_eq!(typed("form[0].name[0]", "261018"), None, "not a date field"); } + +/// Only date clauses are other ways to type a date, and a date is rewritten only when the first +/// pattern keeps it: a later `text{…}` clause is no date pattern, and a year a two-digit first +/// pattern can't hold leaves the text as typed instead of becoming another date. +#[test] +fn a_typed_date_is_rewritten_only_by_date_clauses_into_a_first_pattern_that_keeps_it() { + let (tpl, _) = crate::parse(&crate::fixtures::date_alternatives_template()).unwrap(); + let typed = |som: &str, text: &str| crate::typed_date(&tpl, som, text); + assert_eq!(typed("form[0].code[0]", "261018"), None, "text{{YYMMDD}} is not a date alternative"); + let short = "form[0].short[0]"; + assert_eq!(typed(short, "20261018").as_deref(), Some("261018"), "a year the first pattern keeps"); + assert_eq!(typed(short, "19011018"), None, "1901 as 01 would read back as 2001"); +} From c9fa6a76278c78ea2dc6dd7711528881d6bcd580 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:01:48 -0400 Subject: [PATCH 055/517] M12: a date pattern whose date the first can't keep gives way to a later one --- crates/xfa/src/fixtures.rs | 4 +++- crates/xfa/src/picture.rs | 13 ++++++++----- crates/xfa/src/tests.rs | 7 +++++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 9b4988aef3..cf9bf7ff56 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -507,7 +507,8 @@ pub fn fields_template(data: &str) -> String { /// Date fields whose edit pictures list alternatives, as forms let a date be typed several ways /// (`date{YYYYMMDD}|date{YYMMDD}|…`); one with a single pattern; a text field; `code`, whose -/// only alternative is a text clause; and `short`, whose first pattern has a two-digit year. +/// only alternative is a text clause; `short`, whose first pattern has a two-digit year; and +/// `pick`, where two later patterns read some dates and only one gives a year the first keeps. pub fn date_alternatives_template() -> String { r##" @@ -519,6 +520,7 @@ pub fn date_alternatives_template() -> String { date{YYYYMMDD}|text{YYMMDD} date{YYMMDD}|date{YYYYMMDD} + date{YYMMDD}|date{DDMMYYYY}|date{YYYYMMDD} diff --git a/crates/xfa/src/picture.rs b/crates/xfa/src/picture.rs index 2da57a2b0d..3ddb392114 100644 --- a/crates/xfa/src/picture.rs +++ b/crates/xfa/src/picture.rs @@ -43,11 +43,14 @@ pub fn typed_date(tpl: &Template, som: &str, text: &str) -> Option { if reads(&first).is_some() { return None; } - let iso = alternatives(picture).into_iter().skip(1).filter(|a| is_date_clause(a)).filter_map(date_letters).find_map(|p| reads(&p))?; - // Written in the first pattern only when it reads back as the same date: a year a two-digit - // year can't hold would otherwise become another century's. - let shown = iso_to_pattern(&iso, &first); - (pattern_to_iso(&shown, &first).as_deref() == Some(iso.as_str())).then_some(shown) + // The first later date pattern that reads the text and gives a date the first pattern keeps: + // written in the first, it must read back as the same date (a two-digit year can't hold 1901 + // or 1203), else the next pattern is tried. + alternatives(picture).into_iter().skip(1).filter(|a| is_date_clause(a)).filter_map(date_letters).find_map(|p| { + let iso = reads(&p)?; + let shown = iso_to_pattern(&iso, &first); + (pattern_to_iso(&shown, &first).as_deref() == Some(iso.as_str())).then_some(shown) + }) } /// Whether picture clause `clause` is a date pattern: a `date{…}` clause (with or without a diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index da9c8832ec..24872b35d9 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2677,8 +2677,9 @@ fn a_date_typed_by_another_of_its_edit_pictures_patterns_is_written_in_the_first } /// Only date clauses are other ways to type a date, and a date is rewritten only when the first -/// pattern keeps it: a later `text{…}` clause is no date pattern, and a year a two-digit first -/// pattern can't hold leaves the text as typed instead of becoming another date. +/// pattern keeps it: a later `text{…}` clause is no date pattern, a year a two-digit first +/// pattern can't hold leaves the text as typed instead of becoming another date, and a pattern +/// whose date the first can't keep gives way to a later one whose date it can. #[test] fn a_typed_date_is_rewritten_only_by_date_clauses_into_a_first_pattern_that_keeps_it() { let (tpl, _) = crate::parse(&crate::fixtures::date_alternatives_template()).unwrap(); @@ -2687,4 +2688,6 @@ fn a_typed_date_is_rewritten_only_by_date_clauses_into_a_first_pattern_that_keep let short = "form[0].short[0]"; assert_eq!(typed(short, "20261018").as_deref(), Some("261018"), "a year the first pattern keeps"); assert_eq!(typed(short, "19011018"), None, "1901 as 01 would read back as 2001"); + // DDMMYYYY reads 20011203 as 1203-01-20, which YYMMDD can't keep; YYYYMMDD reads 2001-12-03. + assert_eq!(typed("form[0].pick[0]", "20011203").as_deref(), Some("011203"), "the next pattern that the first keeps"); } From ae69aea6273499f1eee5ec869518a42a707d0117 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:22:27 -0400 Subject: [PATCH 056/517] M6: doc comments on every Panel variant --- crates/js/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 3299f9d6ec..4a4b5e64be 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -167,11 +167,15 @@ pub enum Request { /// A side panel a script can show or hide (`app.execMenuItem("ShowHideFileAttachment")`, …). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Panel { + /// The document's bookmarks (outline). Bookmarks, /// Page thumbnails. Pages, + /// The files attached to the document. Attachments, + /// The document's signatures and their validity. Signatures, + /// The document's layers (optional content). Layers, } From 59163e3ad3a199b1c152beb93a17676bacd249c6 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 18:25:38 +0500 Subject: [PATCH 057/517] Redaction: a per-pass form budget, so depth 50 cannot fan out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #3670: with the depth limit at 50, depth was the only bound on the form walk. A form that draws the next level twice, or a cycle of two forms, costs 2^depth interpretations — at 12 that was 4096, at 50 it is a hang on a file anyone can send. The pass now also counts the forms it enters, and the budget is Edit text's own `MAX_FORM_VISITS`, referenced rather than copied. Running out is treated as a form that cannot be read, which is what the depth limit already did: removed in Apply, the page left alone in Verify. Measured with the new test: depth 50, fan-out and a 50-deep cycle, with the budget 5.9 s depth 22, fan-out, budget removed still running after 5 minutes A `const` assertion ties this pass's depth limit to Edit text's, so the build fails rather than a redaction quietly removing what a reader still shows (the review's third point). Checked by setting the limit back to 12: error[E0080]: evaluation panicked: assertion failed: MAX_DEPTH >= pdfcraft_edit::text::MAX_FORM_DEPTH Tests: a page whose forms each draw the next twice, and the same with the last form drawing the first again — both must finish and still redact the page. --- crates/redact/src/interp.rs | 22 +++++++++++++-- crates/redact/src/tests.rs | 55 +++++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 3d03878e8b..bc34cc00be 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -34,7 +34,18 @@ use pdfcraft_fonts::pdf::Metrics; /// At 12, a page with a 13-deep chain lost the text of its innermost form even with nothing in it /// marked (#3645); past 50 nothing is drawn by any of the three, so removing it costs nothing /// visible. -const MAX_DEPTH: usize = 50; +pub(crate) const MAX_DEPTH: usize = 50; + +/// How many forms one pass enters. The depth limit alone bounded the walk while it was 12: a form +/// that draws the next level twice, or a cycle of two forms, costs 2^depth interpretations, which +/// at 50 is a hang rather than a limit. Edit text's reader pairs its depth limit with the same +/// budget, and this is that constant, so the two cannot drift apart. Running out is treated as a +/// form that cannot be read: removed in Apply, counted as unreadable in Verify. +pub(crate) const MAX_FORM_VISITS: usize = pdfcraft_edit::text::MAX_FORM_VISITS; + +// A form a reader still shows must be walkable here, or a redaction removes content that is +// visible; the build says so rather than a test, so the two limits cannot drift apart. +const _: () = assert!(MAX_DEPTH >= pdfcraft_edit::text::MAX_FORM_DEPTH); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Mode { @@ -100,6 +111,8 @@ pub(crate) struct Scope<'a> { fonts: HashMap, Rc>, used_names: Vec>, depth: usize, + /// Forms entered so far in this pass ([`MAX_FORM_VISITS`]). + visits: usize, } impl<'a> Scope<'a> { @@ -114,6 +127,7 @@ impl<'a> Scope<'a> { fonts: HashMap::new(), used_names: Vec::new(), depth: 0, + visits: 0, } } @@ -989,7 +1003,10 @@ fn xobject( { return None; } - if scope.depth >= MAX_DEPTH { + // Too deep, or too many forms for one page: either way this form cannot be read, so + // it goes (Apply) or the page is left alone (Verify), as a form that will not decode + // does. The budget is what keeps a fan-out or a cycle from costing 2^50 walks. + if scope.depth >= MAX_DEPTH || scope.visits >= MAX_FORM_VISITS { if scope.mode == Mode::Verify { return None; } @@ -998,6 +1015,7 @@ fn xobject( out.doomed.push(r); return Some(None); } + scope.visits += 1; let data = match s.decoded() { Ok(d) => d, Err(_) if scope.mode == Mode::Apply => { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 9d16fd534c..115dbabd90 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -336,9 +336,60 @@ fn a_deeply_nested_form_keeps_the_text_nobody_marked() { // Past what any reader follows, the form is still removed: the fail-safe, and nothing the // renderer draws is lost because it stops at 50 too. - let mut doc = page_with_chain(51); + let mut doc = page_with_chain(crate::interp::MAX_DEPTH + 1); mark(&mut doc, 0, &[[5.0, 275.0, 60.0, 295.0]], ""); - assert_eq!(apply(&mut doc, None).unwrap().forms_removed, 1, "a chain past 50 is removed"); + assert_eq!(apply(&mut doc, None).unwrap().forms_removed, 1, "a chain past the limit is removed"); +} + +/// Depth alone does not bound the walk: a form that draws the next level twice, or a cycle of two +/// forms, costs 2^depth interpretations, which at a depth limit of 50 is a hang on a file anyone +/// can send. The pass also counts the forms it enters, and a page past that budget is treated as +/// one it cannot read. +#[test] +fn a_form_that_fans_out_or_loops_is_bounded_by_the_visit_budget() { + /// A page drawing `Fm1`, where each of `n` forms draws the next **twice** and the last draws + /// text: 2^n paths if nothing stops the walk. With `looping`, the last form draws the first + /// one again instead, so the chain has no end at all. + fn fan_out(n: usize, looping: bool) -> Document { + let first = 7; + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 {first} 0 R >> >> >>" + ) + .into_bytes(), + stream("", b"BT /F1 10 Tf 10 280 Td (HEAD) Tj ET /Fm1 Do"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + ]; + for k in 1..=n { + let (next, body) = if k < n { + (first + k, format!("/Fm{0} Do /Fm{0} Do", k + 1)) + } else if looping { + (first, "/Fm1 Do /Fm1 Do".to_string()) + } else { + (first, "BT /F1 10 Tf 10 150 Td (DEEP) Tj ET".to_string()) + }; + let name = if k < n { format!("/Fm{}", k + 1) } else { "/Fm1".to_string() }; + let res = format!("/Resources << /Font << /F1 5 0 R >> /XObject << {name} {next} 0 R >> >>"); + objs.push(stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] {res}"), body.as_bytes())); + } + pdf(objs) + } + + for looping in [false, true] { + let start = std::time::Instant::now(); + let mut doc = fan_out(crate::interp::MAX_DEPTH, looping); + mark(&mut doc, 0, &[[5.0, 275.0, 60.0, 295.0]], ""); + let report = apply(&mut doc, None).unwrap(); + let took = start.elapsed(); + assert!(took < std::time::Duration::from_secs(20), "looping {looping}: the walk took {took:?}"); + // The page was still redacted, and the budget stopped the walk rather than the pass. + assert!(report.forms_removed > 0, "looping {looping}: the forms past the budget are removed"); + let c = content(&doc, 0); + assert!(!c.contains("(HEAD)"), "looping {looping}: the marked text is gone: {c}"); + } } #[test] From b98d8a28191b89f4b3a068543887af647f8ec953 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Thu, 22 Oct 2026 15:47:54 +0200 Subject: [PATCH 058/517] M6: form_fill also warns about lines too tall or too wide for their box (#4018) Two cases still drew a value cut off without saying so. A single-line value at a fixed font size taller than the box (12 pt text in an 8 pt field) was checked only for width, so it fit sideways and was cut at the top and bottom. In a multiline field, a character wider than the box becomes a line of its own, which fits vertically but is cut at the sides. The single-line and comb branch now compares the line's ascent to descent against the clip box (a new `Overflow::High`), and the multiline branch reports the widest wrapped line that is still wider than the box. An empty value draws nothing, so it is never reported, however small the box. Fixes #4018 --- crates/forms/src/appearance.rs | 17 +++++++++++- crates/forms/src/tests.rs | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 651f8e3208..9b52c5a33e 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -460,6 +460,8 @@ pub enum Overflow { Wide { width: f64, room: f64, size: f64 }, /// `lines` wrapped lines, of which the box shows `room`, at `size` points. Tall { lines: usize, room: usize, size: f64 }, + /// One line `height` points high (ascent to descent), with `room` for it, at `size` points. + High { height: f64, room: f64, size: f64 }, } impl std::fmt::Display for Overflow { @@ -469,6 +471,9 @@ impl std::fmt::Display for Overflow { write!(f, "the value does not fit: {} pt wide at {} pt, room for {} pt", fmt(*width), fmt(*size), fmt(*room)) } Self::Tall { lines, room, size } => write!(f, "the value does not fit: {lines} lines at {} pt, room for {room}", fmt(*size)), + Self::High { height, room, size } => { + write!(f, "the value does not fit: {} pt high at {} pt, room for {} pt", fmt(*height), fmt(*size), fmt(*room)) + } } } } @@ -589,6 +594,9 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] } if room < lines.len() { overflow = Some(Overflow::Tall { lines: lines.len(), room, size }); + } else if let Some(tw) = lines.iter().map(|l| width_of(l, size)).filter(|tw| *tw > inner_w + 0.01).reduce(f64::max) { + // A character wider than the box is a line of its own, still cut at the sides. + overflow = Some(Overflow::Wide { width: tw, room: inner_w, size }); } } else { if size == 0.0 { @@ -601,6 +609,11 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] } // Centre Helvetica's ascent (0.718) and descent (0.207) vertically. let y = (height - 0.925 * size) / 2.0 + 0.207 * size; + // The clip box is inside the border; a line taller than it is cut at the top and bottom. + let room_h = height - 2.0 * bw; + if 0.925 * size > room_h + 0.01 { + overflow = Some(Overflow::High { height: 0.925 * size, room: room_h, size }); + } if comb { let cells = f.max_len.unwrap_or(1).max(1); let cell = width / cells as f64; @@ -610,7 +623,7 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] } } else { let tw = width_of(&text.replace(['\n', '\r'], " "), size); - if tw > inner_w + 0.01 { + if overflow.is_none() && tw > inner_w + 0.01 { overflow = Some(Overflow::Wide { width: tw, room: inner_w, size }); } show(&mut body, x_for(&text, size), y, &text); @@ -643,6 +656,8 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] d.set(b"Subtype".to_vec(), Object::name("Form")); set_form_box(&mut d, place); d.set(b"Resources".to_vec(), Object::Dict(res)); + // Nothing drawn is nothing cut, however small the box. + let overflow = overflow.filter(|_| !text.is_empty()); (Stream::flate(d, &content), unencodable, overflow) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 84ce879847..237b9d1092 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3018,3 +3018,50 @@ fn a_value_wider_or_taller_than_its_box_is_reported() { assert_eq!(overflow("wide"), None); assert_eq!(overflow("short"), None); } + +/// #4018: a single line taller than its box is cut at the top and bottom, and a character wider +/// than a multiline box is a line of its own that is still cut at the sides; both are reported. +/// An empty value draws nothing, so nothing is cut however small the box. +#[test] +fn a_line_taller_than_its_box_or_a_character_wider_than_it_is_reported() { + let field = |name: &str, rect: &str, extra: &str, value: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) /Rect [{rect}] /P 3 0 R {extra} >>") + }; + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + field("low", "50 700 190 708", "", "Ada"), + field("comb", "50 650 190 658", "/Ff 16777216 /MaxLen 5", "Ada"), + field("narrow", "50 500 56 560", "/Ff 4096", "WW"), + field("roomy", "50 400 190 418", "", "Ada"), + field("empty", "50 350 190 358", "", ""), + field("empty-multi", "50 300 190 304", "/Ff 4096", ""), + ]); + let overflow = |name: &str| { + let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap(); + appearance::value_overflow(&doc, &f, &f.widgets[0]) + }; + for name in ["low", "comb"] { + match overflow(name) { + Some(o @ appearance::Overflow::High { height, room, size }) => { + assert_eq!(size, 12.0); + assert!((height - 11.1).abs() < 0.01 && room <= 8.0, "{name}: {height} in {room}"); + assert!(o.to_string().starts_with("the value does not fit: 11.1 pt high at 12 pt"), "{o}"); + } + other => panic!("{name}: {other:?}"), + } + } + match overflow("narrow") { + Some(appearance::Overflow::Wide { width, room, size }) => { + assert_eq!(size, 12.0); + assert!(width > room && (width - 11.32).abs() < 0.1, "{width} in {room}"); + } + other => panic!("narrow: {other:?}"), + } + assert_eq!(overflow("roomy"), None); + assert_eq!(overflow("empty"), None); + assert_eq!(overflow("empty-multi"), None); +} From 62c595ceba2f93e1708a1f5e7e358dae988d3ba5 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:06:00 +0800 Subject: [PATCH 059/517] M3: a fresh install follows the desktop's appearance (#1248) The theme preference defaults to System: dark on a dark desktop, light where the desktop says nothing. Saved settings and --theme still win. Co-Authored-By: Claude Fable 5.1 --- ROADMAP.md | 2 ++ crates/ui-egui/src/lib.rs | 3 ++- crates/ui-egui/src/theme.rs | 5 +++-- crates/ui-egui/tests/it/theme.rs | 23 ++++++++++++++++++++++- parity/acrobat-features.toml | 4 ++-- 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bc057209c2..dc7f544d7a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -343,6 +343,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (community, M14, Polish signature panel):** A Polish signature panel listed its details in English: the catalog lacked every signature wording, the changes after signing they name and 34 UI literals. All 101 are translated; `polish_covers_ui_literals` and `polish_says_what_a_signature_says` keep them so. Estimates unchanged. - **2026-10-14 (M14, Finnish localization):** Added Finnish as another interface and date language, with a 2,674-entry catalog covering every key of the other bundled catalogs and two-form plurals (partitive after numbers other than 1). Calendar dates use partitive month names (`14. lokakuuta 2026`). Coverage tests check commands/tools, scanned UI literals and bundled catalog keys. AI-assisted; native proofreading pending. - **2026-10-14 (M14, Catalan localization):** Added Catalan as another interface and date language, with a 2,592-entry clean-room catalog, one/other plurals and coverage tests for commands, tools, UI literals and every key in the other catalogs. A native Catalan speaker (the contributor) has reviewed the translation. Estimates unchanged. + +- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64b22ff18e..505f3e6a09 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -957,7 +957,8 @@ impl PdfCraftApp { full_screen_tool: None, comment_prefs: Default::default(), theme: ThemeKind::Light, - theme_preference: ThemePreference::Light, + // A fresh install follows the desktop's appearance (#1248); saved settings override. + theme_preference: ThemePreference::System, reading_colors: Default::default(), #[cfg(target_os = "linux")] desktop_theme: None, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 2cd232425e..5adb41696d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -13,11 +13,12 @@ pub enum ThemeKind { Dark, } -/// The saved user choice, independent of the light/dark colours currently displayed. +/// The saved user choice, independent of the light/dark colours currently displayed. A fresh +/// install follows the system (#1248); a desktop that says nothing gets Light. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] pub enum ThemePreference { - System, #[default] + System, Light, Dark, } diff --git a/crates/ui-egui/tests/it/theme.rs b/crates/ui-egui/tests/it/theme.rs index 6e86c5099f..11d6a94dff 100644 --- a/crates/ui-egui/tests/it/theme.rs +++ b/crates/ui-egui/tests/it/theme.rs @@ -48,10 +48,31 @@ fn open_view_menu(h: &mut Harness<'static, PdfCraftApp>) { } } +/// A fresh install follows the desktop's appearance (#1248): dark on a dark desktop, light where +/// the desktop says nothing; a saved choice still wins. +#[test] +fn a_fresh_install_follows_the_system_theme() { + let h = harness(); + assert_theme(&h, ThemePreference::System, ThemeKind::Dark); + let mut quiet = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); + quiet.run_steps(4); + assert_theme(&quiet, ThemePreference::System, ThemeKind::Light); + let mut app = PdfCraftApp::new(); + app.restore(r#"{"theme":"Light"}"#); + assert_eq!(app.theme_preference, ThemePreference::Light, "a saved choice wins over the default"); +} + #[test] fn toolbar_and_view_menu_share_three_state_preferences() { let mut h = harness(); - // Explicit light wins even on a dark desktop, including egui's popup style. + // Fresh, on a dark desktop: System, shown dark. One click on, explicit light wins even on a + // dark desktop, including egui's popup style. + assert_theme(&h, ThemePreference::System, ThemeKind::Dark); + click_toolbar(&mut h, "Use system setting"); assert_theme(&h, ThemePreference::Light, ThemeKind::Light); click_toolbar(&mut h, "Light gray"); assert_theme(&h, ThemePreference::Dark, ThemeKind::Dark); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 362c2aa776..081dcf7e54 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1670,8 +1670,8 @@ milestone = "M3" status = "shipped" commands = ["view.theme.system"] tools = [] -evidence = ["crates/ui-egui/tests/it/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/tests/it/theme.rs::saved_preferences_restore_with_legacy_light_dark_settings", "crates/ui-egui/tests/it/control.rs::theme_commands_and_options_report_preference_and_effective_colours"] -notes = "Toolbar, Display theme and --theme system share a persisted System preference that follows OS changes without losing the selected mode; ui.state reports preference and resolved colours separately." +evidence = ["crates/ui-egui/tests/it/theme.rs::a_fresh_install_follows_the_system_theme", "crates/ui-egui/tests/it/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/tests/it/theme.rs::saved_preferences_restore_with_legacy_light_dark_settings", "crates/ui-egui/tests/it/control.rs::theme_commands_and_options_report_preference_and_effective_colours"] +notes = "Toolbar, Display theme and --theme system share a persisted System preference that follows OS changes without losing the selected mode; ui.state reports preference and resolved colours separately. System is the default on a fresh install (#1248), Light where the desktop says nothing; a saved choice wins." [[feature]] id = "view.home-view" From 7e94c245325eb71da349f9a5649b8a5d53d1245c Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:59:51 +0800 Subject: [PATCH 060/517] M3: the theme button test starts on System (#1248) The click-cycle test assumed a fresh install starts Light; it starts on System now, so the cycle is System, Light, Dark. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/tests/it/theme.rs | 7 ++++--- crates/ui-egui/tests/it/theme_button.rs | 10 +++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/tests/it/theme.rs b/crates/ui-egui/tests/it/theme.rs index 11d6a94dff..1559838dd0 100644 --- a/crates/ui-egui/tests/it/theme.rs +++ b/crates/ui-egui/tests/it/theme.rs @@ -237,12 +237,13 @@ fn preferences_theme_cards_and_use_system_setting_stay_in_sync() { combo.value().unwrap_or_default() } let mut h = harness(); - h.get_by_label("Display theme: Light gray").click_secondary(); + h.get_by_label("Display theme: Use system setting").click_secondary(); h.run_steps(4); h.get_by_label("Appearance settings…").click(); h.run_steps(10); - assert_theme(&h, ThemePreference::Light, ThemeKind::Light); - assert_eq!(mode_shown(&h), "Light"); + // A fresh install follows the OS (#1248), which is dark here. + assert_theme(&h, ThemePreference::System, ThemeKind::Dark); + assert_eq!(mode_shown(&h), "Use system setting"); // The OS is dark: the Dark card's radio row picks Dark, and the combo box says so. // The cards sit below the fold of the Preferences scroll area at this window size. diff --git a/crates/ui-egui/tests/it/theme_button.rs b/crates/ui-egui/tests/it/theme_button.rs index 14f34086ce..758aa1a277 100644 --- a/crates/ui-egui/tests/it/theme_button.rs +++ b/crates/ui-egui/tests/it/theme_button.rs @@ -1,5 +1,5 @@ -//! The title bar's theme button: a click cycles System, Light and Dark; a right-click opens the -//! menu with the appearance settings. +//! The title bar's theme button: a click cycles System, Light and Dark (a fresh install starts +//! on System, #1248); a right-click opens the menu with the appearance settings. use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -22,9 +22,9 @@ fn preference(h: &Harness<'static, PdfCraftApp>) -> String { #[test] fn a_click_cycles_the_theme_and_a_right_click_opens_the_menu() { let mut h = harness(); - assert_eq!(preference(&h), "Light", "new installs start Light"); + assert_eq!(preference(&h), "System", "a fresh install follows the desktop (#1248)"); for (label, next) in - [("Display theme: Light gray", "Dark"), ("Display theme: Dark gray", "System"), ("Display theme: Use system setting", "Light")] + [("Display theme: Use system setting", "Light"), ("Display theme: Light gray", "Dark"), ("Display theme: Dark gray", "System")] { h.get_by_label(label).click(); h.run_steps(4); @@ -32,7 +32,7 @@ fn a_click_cycles_the_theme_and_a_right_click_opens_the_menu() { } assert!(h.query_by_label("Appearance settings…").is_none(), "a plain click opens no menu"); - h.get_by_label("Display theme: Light gray").click_secondary(); + h.get_by_label("Display theme: Use system setting").click_secondary(); h.run_steps(4); h.get_by_label("Appearance settings…").click(); h.run_steps(4); From 1240c95592948b85a397da5ef7f0add9595ca888 Mon Sep 17 00:00:00 2001 From: Adrien Alberti Date: Thu, 22 Oct 2026 17:43:59 +0200 Subject: [PATCH 061/517] M12: keep the 1:N field in step with the scale fields; review fixes - ratio_text is derived from the scale fields (one function, sync_ratio_text): on page seeding and after a hand edit of drawing points, real distance, unit or page unit it shows the matching 1:N, or is emptied when the fields are not a plain m/mm drawing scale. The "Common scales" highlight follows it. - format_ratio no longer rounds (1:1.234, 1:0.001), so the field shows exactly the ratio that applies. - The smallest accepted ratio is 1:0.001, keeping the real distance within the minimum of its field; set_ratio refuses out-of-range ratios too. - "Scale", "Common scales", "Enter a scale such as 1:50." and a hint that applying a scale switches units to m / mm are in every i18n catalog, with a completeness test. Co-Authored-By: Claude --- crates/ui-egui/src/i18n/ar.tsv | 4 ++ crates/ui-egui/src/i18n/bg.tsv | 4 ++ crates/ui-egui/src/i18n/ca.tsv | 4 ++ crates/ui-egui/src/i18n/ckb.tsv | 4 ++ crates/ui-egui/src/i18n/cs.tsv | 4 ++ crates/ui-egui/src/i18n/de.tsv | 4 ++ crates/ui-egui/src/i18n/el.tsv | 4 ++ crates/ui-egui/src/i18n/es.tsv | 4 ++ crates/ui-egui/src/i18n/fi.tsv | 4 ++ crates/ui-egui/src/i18n/fr.tsv | 4 ++ crates/ui-egui/src/i18n/hi.tsv | 4 ++ crates/ui-egui/src/i18n/hu.tsv | 4 ++ crates/ui-egui/src/i18n/id.tsv | 4 ++ crates/ui-egui/src/i18n/it.tsv | 4 ++ crates/ui-egui/src/i18n/ja.tsv | 4 ++ crates/ui-egui/src/i18n/kk.tsv | 4 ++ crates/ui-egui/src/i18n/ko.tsv | 4 ++ crates/ui-egui/src/i18n/lo.tsv | 4 ++ crates/ui-egui/src/i18n/lt.tsv | 4 ++ crates/ui-egui/src/i18n/mod.rs | 12 ++++ crates/ui-egui/src/i18n/nb.tsv | 4 ++ crates/ui-egui/src/i18n/pl.tsv | 4 ++ crates/ui-egui/src/i18n/pt-br.tsv | 4 ++ crates/ui-egui/src/i18n/ro.tsv | 4 ++ crates/ui-egui/src/i18n/ru.tsv | 4 ++ crates/ui-egui/src/i18n/sl.tsv | 4 ++ crates/ui-egui/src/i18n/sq.tsv | 4 ++ crates/ui-egui/src/i18n/te.tsv | 4 ++ crates/ui-egui/src/i18n/th.tsv | 4 ++ crates/ui-egui/src/i18n/tr.tsv | 4 ++ crates/ui-egui/src/i18n/uk.tsv | 4 ++ crates/ui-egui/src/i18n/vi.tsv | 4 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 4 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 4 ++ crates/ui-egui/src/measure_ui.rs | 92 ++++++++++++++++++++++++++--- 35 files changed, 229 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 765bbba553..00e0aeed3c 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2040,6 +2040,10 @@ Perimeter المحيط Area المساحة Drawing scale مقياس الرسم + Scale المقياس + Common scales مقاييس شائعة + Enter a scale such as 1:50. أدخل مقياسًا مثل 1:50. + Applying a scale switches units to m / mm. يؤدي تطبيق المقياس إلى تحويل الوحدات إلى م / مم. Snapping الانجذاب Export measurements تصدير القياسات Open a PDF to measure it. افتح ملف PDF لقياسه. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9bb377a936..00f89fa749 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1902,6 +1902,10 @@ Perimeter Периметър Area Площ Drawing scale Мащаб на чертежа + Scale Мащаб + Common scales Често използвани мащаби + Enter a scale such as 1:50. Въведете мащаб, например 1:50. + Applying a scale switches units to m / mm. Прилагането на мащаб превключва единиците на m / mm. Snapping Прилепване Export measurements Експортирай измерванията Open a PDF to measure it. Отворете PDF файл, за да го измервате. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9cad6ad5ec..7e5f890399 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2000,6 +2000,10 @@ action wizard Start Inicia Perimeter Perímetre Area Àrea Drawing scale Escala del dibuix + Scale Escala + Common scales Escales habituals + Enter a scale such as 1:50. Introduïu una escala com 1:50. + Applying a scale switches units to m / mm. En aplicar una escala, les unitats canvien a m / mm. Snapping Ajust Export measurements Exporta les mesures Open a PDF to measure it. Obre un PDF per mesurar-lo. diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 804e7a97a1..e4a7362c19 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2003,6 +2003,10 @@ Perimeter چێوە Area ڕووبەر Drawing scale پێوەری وێنەکێشان + Scale پێوەر + Common scales پێوەرە باوەکان + Enter a scale such as 1:50. پێوەرێک بنووسە وەک 1:50. + Applying a scale switches units to m / mm. جێبەجێکردنی پێوەر یەکەکان دەگۆڕێت بۆ م / ملم. Snapping لکاندن Export measurements هەناردەکردنی پێوانەکان Open a PDF to measure it. PDFێک بکەرەوە بۆ پێوانەکردنی. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4e736bbf46..7da850f2b1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2087,6 +2087,10 @@ key Space Mezerník Perimeter Obvod Area Plocha Drawing scale Měřítko výkresu + Scale Měřítko + Common scales Běžná měřítka + Enter a scale such as 1:50. Zadejte měřítko, například 1:50. + Applying a scale switches units to m / mm. Použití měřítka přepne jednotky na m / mm. Snapping Přichytávání Export measurements Exportovat měření Open a PDF to measure it. Otevřete PDF, které chcete měřit. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 76366d452f..1bde60ae18 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1976,6 +1976,10 @@ Perimeter Umfang Area Fläche Drawing scale Zeichnungsmaßstab + Scale Maßstab + Common scales Gängige Maßstäbe + Enter a scale such as 1:50. Geben Sie einen Maßstab wie 1:50 ein. + Applying a scale switches units to m / mm. Beim Anwenden eines Maßstabs werden die Einheiten auf m / mm umgestellt. Snapping Einrasten Export measurements Messungen exportieren Open a PDF to measure it. Öffne ein PDF, um es zu messen. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 18464664ac..7d6922552a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1417,6 +1417,10 @@ Perimeter Περίμετρος Area Εμβαδόν Drawing scale Κλίμακα σχεδίου + Scale Κλίμακα + Common scales Συνηθισμένες κλίμακες + Enter a scale such as 1:50. Εισαγάγετε μια κλίμακα, όπως 1:50. + Applying a scale switches units to m / mm. Η εφαρμογή κλίμακας αλλάζει τις μονάδες σε m / mm. Snapping Προσκόλληση Export measurements Εξαγωγή μετρήσεων Open a PDF to measure it. Ανοίξτε ένα PDF για να πραγματοποιήσετε μετρήσεις. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index db00c69dd6..2dfc387498 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2056,6 +2056,10 @@ action wizard Start Iniciar Perimeter Perímetro Area Área Drawing scale Escala del dibujo + Scale Escala + Common scales Escalas habituales + Enter a scale such as 1:50. Introduzca una escala como 1:50. + Applying a scale switches units to m / mm. Al aplicar una escala, las unidades cambian a m / mm. Snapping Ajuste Export measurements Exportar mediciones Open a PDF to measure it. Abre un PDF para medirlo. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ddc299823..81b7964713 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1978,6 +1978,10 @@ Perimeter Ympärysmitta Area Pinta-ala Drawing scale Piirustuksen mittakaava + Scale Mittakaava + Common scales Yleiset mittakaavat + Enter a scale such as 1:50. Anna mittakaava, esimerkiksi 1:50. + Applying a scale switches units to m / mm. Mittakaavan käyttö vaihtaa yksiköiksi m / mm. Snapping Tartunta Export measurements Vie mittaukset Open a PDF to measure it. Avaa PDF mitataksesi sitä. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ec6e59e56d..490837d567 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1971,6 +1971,10 @@ Perimeter Périmètre Area Aire Drawing scale Échelle du dessin + Scale Échelle + Common scales Échelles courantes + Enter a scale such as 1:50. Saisissez une échelle comme 1:50. + Applying a scale switches units to m / mm. Appliquer une échelle passe les unités en m / mm. Snapping Accrochage Export measurements Exporter les mesures Open a PDF to measure it. Ouvrez un PDF pour le mesurer. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7be5ec6883..bcdcac9a31 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1961,6 +1961,10 @@ Perimeter परिमाप Area क्षेत्रफल Drawing scale रेखाचित्र पैमाना + Scale पैमाना + Common scales सामान्य पैमाने + Enter a scale such as 1:50. 1:50 जैसा पैमाना दर्ज करें। + Applying a scale switches units to m / mm. पैमाना लागू करने पर इकाइयाँ m / mm में बदल जाती हैं। Snapping स्नैपिंग Export measurements मापन निर्यात करें Open a PDF to measure it. मापने के लिए एक पीडीएफ़ खोलें। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f1723629cc..ba244a898e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1961,6 +1961,10 @@ Perimeter Kerület Area Terület Drawing scale Rajz méretaránya + Scale Méretarány + Common scales Gyakori méretarányok + Enter a scale such as 1:50. Adjon meg egy méretarányt, például 1:50. + Applying a scale switches units to m / mm. A méretarány alkalmazása m / mm mértékegységre vált. Snapping Illesztés Export measurements Mérések exportálása Open a PDF to measure it. Nyiss meg egy PDF-et a méréshez. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3585b344e1..58a4613821 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1390,6 +1390,10 @@ Draw freehand Gambar bebas Drawing Gambar Drawing scale Skala gambar + Scale Skala + Common scales Skala umum + Enter a scale such as 1:50. Masukkan skala seperti 1:50. + Applying a scale switches units to m / mm. Menerapkan skala mengubah satuan menjadi m / mm. Drop-down list Daftar drop-down Duplicate pages Duplikat halaman Edit a PDF Edit PDF diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0dc6bb0874..49f622af56 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2047,6 +2047,10 @@ Perimeter Perimetro Area Area Drawing scale Scala del disegno + Scale Scala + Common scales Scale comuni + Enter a scale such as 1:50. Inserisci una scala come 1:50. + Applying a scale switches units to m / mm. Applicando una scala le unità passano a m / mm. Snapping Aggancio Export measurements Esporta misure Open a PDF to measure it. Apri un PDF per misurarlo. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 963e8d3641..73639521f6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2054,6 +2054,10 @@ Perimeter 周長 Area 面積 Drawing scale 図面の縮尺 + Scale 縮尺 + Common scales よく使う縮尺 + Enter a scale such as 1:50. 1:50 のように縮尺を入力してください。 + Applying a scale switches units to m / mm. 縮尺を適用すると、単位が m / mm に切り替わります。 Snapping スナップ Export measurements 測定結果を書き出す Open a PDF to measure it. 測定するPDFを開いてください。 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 311626f34a..9078773366 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -1962,6 +1962,10 @@ Perimeter Периметр Area Аудан Drawing scale Сызба масштабы + Scale Масштаб + Common scales Жиі қолданылатын масштабтар + Enter a scale such as 1:50. 1:50 сияқты масштабты енгізіңіз. + Applying a scale switches units to m / mm. Масштабты қолдану өлшем бірліктерін m / mm етіп ауыстырады. Snapping Түйістіру Export measurements Өлшемдерді экспорттау Open a PDF to measure it. Өлшеу үшін PDF құжатын ашыңыз. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 48ef31e19b..bfda232cc7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1961,6 +1961,10 @@ Perimeter 둘레 Area 면적 Drawing scale 도면 축척 + Scale 축척 + Common scales 자주 쓰는 축척 + Enter a scale such as 1:50. 1:50과 같은 축척을 입력하세요. + Applying a scale switches units to m / mm. 축척을 적용하면 단위가 m / mm로 바뀝니다. Snapping 스냅 Export measurements 측정값 내보내기 Open a PDF to measure it. 측정할 PDF를 여세요. diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index a8a9f6f643..4aa8e4a6d5 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2014,6 +2014,10 @@ Perimeter ລວງຮອບ Area ເນື້ອທີ່ Drawing scale ມາດຕາສ່ວນຮູບແຕ້ມ + Scale ມາດຕາສ່ວນ + Common scales ມາດຕາສ່ວນທີ່ໃຊ້ທົ່ວໄປ + Enter a scale such as 1:50. ປ້ອນມາດຕາສ່ວນ ເຊັ່ນ 1:50. + Applying a scale switches units to m / mm. ການນຳໃຊ້ມາດຕາສ່ວນຈະປ່ຽນຫົວໜ່ວຍເປັນ m / mm. Snapping ການດູດຈັບ Export measurements ສົ່ງອອກການວັດແທກ Open a PDF to measure it. ເປີດ PDF ເພື່ອວັດແທກ. diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 0e542211f4..2b9d886ec0 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -1449,6 +1449,10 @@ Draw freehand Brėžti laisva ranka Drawing Brėžinys Drawing scale Brėžinio mastelis + Scale Mastelis + Common scales Dažni masteliai + Enter a scale such as 1:50. Įveskite mastelį, pavyzdžiui, 1:50. + Applying a scale switches units to m / mm. Pritaikius mastelį, vienetai pakeičiami į m / mm. Drop-down list Išskleidžiamasis sąrašas Duplicate pages Dubliuoti puslapius Edit a PDF Redaguoti PDF diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index dff8efc3de..76d530dc1f 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -1102,6 +1102,18 @@ mod tests { } } + /// The drawing-scale labels of the Measure panel are translated in every catalog. + #[test] + fn drawing_scale_labels_are_translated_in_every_catalog() { + for language in LANGUAGES.iter().filter(|language| !language.source.is_empty()) { + let lang = Lang::from_code(language.code).expect("registered language"); + for label in ["Scale", "Common scales", "Enter a scale such as 1:50.", "Applying a scale switches units to m / mm."] { + assert!(has(lang, label), "{} lacks {label:?}", language.code); + assert_ne!(tr(lang, label), label, "{} falls back to English for {label:?}", language.code); + } + } + } + /// Every catalog says the labels of a visible signature, which are looked up by the sign /// crate's own English words (so the `tl!` scan can't see them), and the note shown when they /// fall back to English. Each label that runs into its value keeps a separator at its end. diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 27c1383ec2..62a2a9a49f 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -1401,6 +1401,10 @@ Draw freehand Tegn på frihånd Drawing Tegning Drawing scale Tegneskala + Scale Målestokk + Common scales Vanlige målestokker + Enter a scale such as 1:50. Skriv inn en målestokk, for eksempel 1:50. + Applying a scale switches units to m / mm. Når du bruker en målestokk, byttes enhetene til m / mm. Drop-down list Nedtrekksliste Duplicate pages Dupliser sider Edit a PDF Rediger en PDF diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cd381b5393..c0e0d03ebc 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2034,6 +2034,10 @@ Perimeter Obwód Area Pole powierzchni Drawing scale Skala rysunku + Scale Skala + Common scales Popularne skale + Enter a scale such as 1:50. Wpisz skalę, na przykład 1:50. + Applying a scale switches units to m / mm. Zastosowanie skali zmienia jednostki na m / mm. Snapping Przyciąganie Export measurements Eksportuj pomiary Open a PDF to measure it. Otwórz PDF, aby wykonać pomiar. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8fe72f99b2..3e136ff00f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1774,6 +1774,10 @@ Perimeter Perímetro Area Área Drawing scale Escala de desenho + Scale Escala + Common scales Escalas comuns + Enter a scale such as 1:50. Digite uma escala como 1:50. + Applying a scale switches units to m / mm. Aplicar uma escala muda as unidades para m / mm. Snapping Alinhamento inteligente Export measurements Exportar medições Open a PDF to measure it. Abra um PDF para medi-lo. diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 5a8025dc80..ef0eb30b9a 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -1399,6 +1399,10 @@ Draw freehand Desenare liberă Drawing Desen Drawing scale Scara desenului + Scale Scară + Common scales Scări uzuale + Enter a scale such as 1:50. Introduceți o scară, de exemplu 1:50. + Applying a scale switches units to m / mm. Aplicarea unei scări schimbă unitățile în m / mm. Drop-down list Listă derulantă Duplicate pages Duplicare pagini Edit a PDF Editare PDF diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0dc40fdba6..681a41a2f1 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2041,6 +2041,10 @@ Perimeter Периметр Area Площадь Drawing scale Масштаб чертежа + Scale Масштаб + Common scales Распространённые масштабы + Enter a scale such as 1:50. Введите масштаб, например 1:50. + Applying a scale switches units to m / mm. При применении масштаба единицы переключаются на м / мм. Snapping Привязка Export measurements Экспортировать измерения Open a PDF to measure it. Откройте PDF для измерения. diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index f5e374ae7d..5fc2c3a62e 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -654,6 +654,10 @@ Draw your {what} below. Spodaj narišite: {what}. Drawing Risba Drawing scale Merilo risbe + Scale Merilo + Common scales Pogosta merila + Enter a scale such as 1:50. Vnesite merilo, na primer 1:50. + Applying a scale switches units to m / mm. Z uporabo merila se enote preklopijo na m / mm. Drop PDFs or folders here, or choose them. Spustite datoteke PDF ali mape sem ali jih izberite. Drop-down list Spustni seznam Dropdown Properties Lastnosti spustnega seznama diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index c93efee5a3..a89c990b20 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -645,6 +645,10 @@ Draw your {what} below. Vizato {what} më poshtë. Drawing Vizatim Drawing scale Shkalla e vizatimit + Scale Shkalla + Common scales Shkalla të zakonshme + Enter a scale such as 1:50. Shkruani një shkallë si 1:50. + Applying a scale switches units to m / mm. Zbatimi i një shkalle i kalon njësitë në m / mm. Drop PDFs or folders here, or choose them. Hidh PDF ose dosje këtu, ose zgjidh ato. Drop-down list Listë rënëse Dropdown Properties Vetitë e listës rënëse diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index fa3f6cec4f..3b7d879d5b 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1960,6 +1960,10 @@ action wizard Start ప్రారంభించు Perimeter చుట్టుకొలత Area వైశాల్యం Drawing scale డ్రాయింగ్ స్కేల్ + Scale స్కేల్ + Common scales సాధారణ స్కేల్‌లు + Enter a scale such as 1:50. 1:50 వంటి స్కేల్‌ను నమోదు చేయండి. + Applying a scale switches units to m / mm. స్కేల్‌ను వర్తింపజేస్తే యూనిట్లు m / mm‌కు మారతాయి. Snapping స్నాపింగ్ Export measurements కొలతలను ఎగుమతి చేయి Open a PDF to measure it. కొలవడానికి ఒక PDFను తెరవండి. diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index e56770757c..d4c0230151 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -1984,6 +1984,10 @@ Perimeter เส้นรอบรูป Area พื้นที่ Drawing scale มาตราส่วนของแบบ + Scale มาตราส่วน + Common scales มาตราส่วนที่ใช้บ่อย + Enter a scale such as 1:50. ป้อนมาตราส่วน เช่น 1:50 + Applying a scale switches units to m / mm. การใช้มาตราส่วนจะเปลี่ยนหน่วยเป็น m / mm Snapping การยึด Export measurements ส่งออกการวัด Open a PDF to measure it. เปิด PDF เพื่อวัดระยะ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c292b88638..64d8aa1543 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1934,6 +1934,10 @@ Perimeter Çevre Area Alan Drawing scale Çizim ölçeği + Scale Ölçek + Common scales Yaygın ölçekler + Enter a scale such as 1:50. 1:50 gibi bir ölçek girin. + Applying a scale switches units to m / mm. Ölçek uygulamak birimleri m / mm olarak değiştirir. Snapping Yakalama Export measurements Ölçümleri dışa aktar Open a PDF to measure it. Ölçmek için bir PDF açın. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cc8f45765c..96136a425a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2038,6 +2038,10 @@ Perimeter Периметр Area Площа Drawing scale Масштаб креслення + Scale Масштаб + Common scales Поширені масштаби + Enter a scale such as 1:50. Введіть масштаб, наприклад 1:50. + Applying a scale switches units to m / mm. Застосування масштабу перемикає одиниці на м / мм. Snapping Прив’язування Export measurements Експортувати вимірювання Open a PDF to measure it. Відкрийте PDF для вимірювання. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bb20dcc5b9..d60a8de886 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1439,6 +1439,10 @@ theme Appearance Giao diện Draw freehand Vẽ tự do Drawing Bản vẽ Drawing scale Tỷ lệ bản vẽ + Scale Tỷ lệ + Common scales Tỷ lệ thông dụng + Enter a scale such as 1:50. Nhập một tỷ lệ như 1:50. + Applying a scale switches units to m / mm. Áp dụng tỷ lệ sẽ chuyển đơn vị sang m / mm. Drop-down list Danh sách thả xuống Duplicate pages Nhân bản trang Edit a PDF Chỉnh sửa một PDF diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 594f68df88..fc2bb4007d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1910,6 +1910,10 @@ Perimeter 周长 Area 面积 Drawing scale 图面比例 + Scale 比例 + Common scales 常用比例 + Enter a scale such as 1:50. 请输入比例,例如 1:50。 + Applying a scale switches units to m / mm. 应用比例会将单位切换为 m / mm。 Snapping 吸附 Export measurements 导出测量结果 Open a PDF to measure it. 打开 PDF 以进行测量。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb578ba26f..f0ac381b4f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2041,6 +2041,10 @@ Perimeter 周長 Area 面積 Drawing scale 圖面比例 + Scale 比例 + Common scales 常用比例 + Enter a scale such as 1:50. 請輸入比例,例如 1:50。 + Applying a scale switches units to m / mm. 套用比例會將單位切換為 m / mm。 Snapping 吸附 Export measurements 匯出測量結果 Open a PDF to measure it. 開啟 PDF 以進行測量。 diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index d80ecca3e2..658457499a 100644 --- a/crates/ui-egui/src/measure_ui.rs +++ b/crates/ui-egui/src/measure_ui.rs @@ -115,16 +115,40 @@ pub(crate) fn parse_ratio(text: &str) -> Option { Some((one, n)) => n.trim().parse::().ok()? / one.trim().parse::().ok()?, None => text.parse::().ok()?, }; - (n.is_finite() && (1e-6..=1e9).contains(&n)).then_some(n) + RATIO_RANGE.contains(&n).then_some(n) } -/// "1:200", or "1:2.5" for a ratio that isn't a whole number. +/// The N of 1:N this panel accepts. The lower bound keeps the real distance (N / 1000 m) within the +/// minimum of its field. +const RATIO_RANGE: std::ops::RangeInclusive = 1e-3..=1e9; +/// "1:200", or "1:2.5" for a ratio that isn't a whole number: the number as it is, so the field shows exactly what applies. pub(crate) fn format_ratio(n: f64) -> String { - let rounded = (n * 100.0).round() / 100.0; - if (rounded - rounded.round()).abs() < 1e-9 { format!("1:{}", rounded.round()) } else { format!("1:{rounded}") } + format!("1:{n}") +} +/// The N of 1:N the scale fields stand for, when they are a plain drawing scale (real distance in m or mm). +fn ratio_of_fields(state: &MeasureView, display: crate::units::Unit) -> Option { + let real_mm = match state.unit.trim() { + "m" => state.real_distance * 1000.0, + "mm" => state.real_distance, + _ => return None, + }; + let paper_mm = state.drawing_points * page_unit_of(state.page_unit, &state.unit, display).points() * 25.4 / 72.0; + let n = real_mm / paper_mm; + // Float noise from the unit conversions is rounded away; anything that isn't a clean ratio isn't one. + let clean = (n * 1e6).round() / 1e6; + (RATIO_RANGE.contains(&clean) && (clean - n).abs() <= 1e-9 * n).then_some(clean) +} +/// Make the "1:N" field and the common scale picked in its list say what the scale fields now hold: the ratio, or nothing. +fn sync_ratio_text(state: &mut MeasureView, display: crate::units::Unit) { + state.ratio_text = ratio_of_fields(state, display).map(format_ratio).unwrap_or_default(); + state.ratio_dirty = false; +} +/// What the scale fields hold, to notice a hand edit. +fn scale_fields(state: &MeasureView) -> (f64, f64, String, Option) { + (state.drawing_points, state.real_distance, state.unit.clone(), state.page_unit) } /// Fill the scale fields for 1:N: 1 mm on the page is N mm, shown in metres. Returns `false`, changing nothing, when `n` is not a usable ratio. pub(crate) fn set_ratio(state: &mut MeasureView, n: f64) -> bool { - if !n.is_finite() || n <= 0.0 { + if !RATIO_RANGE.contains(&n) { return false; } state.unit = "m".into(); @@ -365,6 +389,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { state.page_unit = None; state.precision = scale.precision; } + sync_ratio_text(state, units); } ui.label(tl!("Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area.")); ui.label(tl!("Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels.")); @@ -400,8 +425,9 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.separator(); // "1:N" fills the fields below and applies at once, as does picking a common scale. let mut chosen = None; + let fields = scale_fields(state); ui.horizontal(|ui| { - ui.label(tl!("Drawing scale")); + ui.label(tl!("Drawing scale")).on_hover_text(tl!("Applying a scale switches units to m / mm.")); let typed = ui.add(crate::widgets::line(&mut state.ratio_text).hint_text("1:100").desired_width(56.0).char_limit(16)); typed.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tl!("Scale"))); state.ratio_dirty |= typed.changed(); @@ -437,6 +463,10 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.add(crate::widgets::line(&mut state.unit).desired_width(64.0).char_limit(12)); ui.add(egui::DragValue::new(&mut state.precision).range(0..=6).prefix(format!("{} ", tl!("Decimals")))); }); + // A hand edit of the fields leaves the 1:N field showing the old ratio: say the new one. + if scale_fields(state) != fields { + sync_ratio_text(state, units); + } if ui.button(tl!("Calibrate from two points")).clicked() { tool = Some(Tool::Calibrate); } @@ -597,11 +627,15 @@ mod tests { assert_eq!(parse_ratio("250"), Some(250.0)); assert_eq!(parse_ratio("1:2,5"), Some(2.5)); assert_eq!(parse_ratio("2:1"), Some(0.5)); + assert_eq!(parse_ratio("1:0.001"), Some(1e-3)); } #[test] fn invalid_ratios_are_refused() { - for bad in ["", " ", ":", "1:", ":50", "1:0", "0", "-5", "1:-5", "0:5", "abc", "1:abc", "1:50:2", "1:inf", "NaN", "1e12", "1:1e12"] { + for bad in [ + "", " ", ":", "1:", ":50", "1:0", "0", "-5", "1:-5", "0:5", "abc", "1:abc", "1:50:2", "1:inf", "NaN", "1e12", "1:1e12", "1:0.0001", + "1:1e-6", + ] { assert_eq!(parse_ratio(bad), None, "{bad:?}"); } } @@ -611,6 +645,10 @@ mod tests { assert_eq!(format_ratio(50.0), "1:50"); assert_eq!(format_ratio(2.5), "1:2.5"); assert_eq!(format_ratio(0.5), "1:0.5"); + // Nothing is rounded away: the field reads the ratio that applies. + assert_eq!(format_ratio(1.234), "1:1.234"); + assert_eq!(format_ratio(0.001), "1:0.001"); + assert_eq!(parse_ratio(&format_ratio(333.333333)), Some(333.333333)); for n in RATIOS { assert_eq!(parse_ratio(&format_ratio(n)), Some(n)); } @@ -641,6 +679,46 @@ mod tests { assert!((v - 0.05).abs() < 1e-9, "{v}"); } + #[test] + fn the_smallest_ratio_keeps_the_real_distance_within_its_field() { + let mut state = MeasureView::default(); + assert!(set_ratio(&mut state, parse_ratio("1:0.001").expect("ratio"))); + // The real-distance field accepts 0.000001 and no less. + assert!(state.real_distance >= 0.000001, "{}", state.real_distance); + assert!(!set_ratio(&mut state, 1e-4)); + } + + #[test] + fn the_ratio_field_follows_the_scale_fields() { + let display = crate::units::Unit::Points; + let mut state = MeasureView::default(); + for n in [50.0, 2.5, 1.234, 0.001, 1e9] { + assert!(set_ratio(&mut state, n)); + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, format_ratio(n), "1:{n}"); + } + // A hand edit that is still a drawing scale reads as its ratio, in another page unit too. + assert!(set_ratio(&mut state, 50.0)); + state.page_unit = Some(PageUnit::Cm); + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, "1:5"); + state.page_unit = Some(PageUnit::Mm); + state.real_distance = 0.1; + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, "1:100"); + // One that isn't (another unit, or no ratio at all) empties it. + state.unit = "ft".into(); + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, ""); + state.unit = "m".into(); + state.real_distance = 0.1 / 3.0; + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, ""); + state.drawing_points = 0.0; + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, ""); + } + #[test] fn an_unusable_ratio_changes_nothing() { let mut state = MeasureView::default(); From 01744fd7eb1621188de5cce285135c9d287b421c Mon Sep 17 00:00:00 2001 From: Lombardi2003 Date: Tue, 20 Oct 2026 23:41:17 +0200 Subject: [PATCH 062/517] feat(organize): Extract pages takes typed page ranges (#3593) Extract pages only took the selected pages. Its dialog now has a Pages field for ranges such as "1-5, 8, 11-13", read like Combine files' and Print's ranges, in the order typed and each page once. Left empty (as the dialog opens), it takes the selection as before, shown as the hint. A range it can't use says why and turns Extract off; Extract and delete removes the typed pages. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 30 ++++++++++++---- crates/ui-egui/src/files.rs | 46 ++++++++++++++++++++---- crates/ui-egui/tests/it/pickers.rs | 56 ++++++++++++++++++++++++++++-- parity/acrobat-features.toml | 4 +-- 4 files changed, 119 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0337a8cbfb..f4b2aca82d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -594,15 +594,31 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { return; } Dialog::Extract => { - let count = app.active_ids().map_or(0, |(i, _)| app.views[i].target_pages().len()); + let (selected, total) = app.active_ids().map_or((Vec::new(), 0), |(i, _)| (app.views[i].target_pages(), app.views[i].page_count())); ui.label(egui::RichText::new(tl!("Extract pages")).font(theme::semibold(18.0))); ui.add_space(8.0); - let text = if count == 1 { - tl!("1 page selected.").to_string() - } else { - crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &count.to_string())]) + // Page ranges to take instead of the selection (#3593); the hint is the selection. + let hint = match crate::combine_ui::range_of(&selected, total) { + all if all.is_empty() && total > 1 => format!("1-{total}"), + all if all.is_empty() => "1".to_string(), + some => some, }; - ui.label(text); + ui.horizontal(|ui| { + let l = ui.label(tl!("Pages")); + ui.add(egui::TextEdit::singleline(&mut app.extract_draft.pages).hint_text(hint).desired_width(240.0)).labelled_by(l.id); + }); + let chosen = app.extract_draft.pages_to_extract(selected, total); + match &chosen { + Ok(pages) if pages.len() == 1 => { + ui.label(tl!("1 page selected.")); + } + Ok(pages) => { + ui.label(crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &pages.len().to_string())])); + } + Err(e) => { + ui.colored_label(ui.visuals().error_fg_color, e); + } + } ui.add_space(4.0); // The name the pages are saved under (#737); separate files add " (page N)". ui.horizontal(|ui| { @@ -613,7 +629,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add(widgets::checkbox(&mut app.extract_draft.separate, tl!("Extract pages as separate files"))); ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("Extract"), true).clicked() { + if ui.add_enabled_ui(chosen.is_ok(), |ui| widgets::pill_button(ui, tl!("Extract"), true)).inner.clicked() { extract_now = true; close = true; } diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 1ec9d383fc..2995786496 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -166,6 +166,28 @@ pub struct ExtractDraft { /// the document's name when it opens; empty means that name too. As separate files each /// page is ` (page N).pdf`, and a single page the user renamed is `.pdf`. pub name: String, + /// The pages to extract as typed ranges, 1-based ("1-5, 8, 11-13") (#3593). Empty means the + /// selected pages (or the current one); the dialog opens with it empty. + pub pages: String, +} + +impl ExtractDraft { + /// The pages to extract (0-based, in the order typed, each once) from a document of `count` + /// pages: the typed [`Self::pages`] ranges, or `selected` when none are typed. Says what is + /// wrong with a range that can't be read or takes no page. + pub fn pages_to_extract(&self, selected: Vec, count: usize) -> Result, String> { + if self.pages.trim().is_empty() { + return Ok(selected); + } + match pdfcraft_engine::print::select_pages(count, Some(&self.pages), &[], pdfcraft_engine::print::Subset::All, false) { + Ok(p) if p.is_empty() => Err(tl!("No pages selected").to_string()), + Ok(p) => { + let mut seen = std::collections::HashSet::new(); + Ok(p.into_iter().filter(|page| seen.insert(*page)).collect()) + } + Err(e) => Err(e.to_string()), + } + } } /// `name` made safe as a file name on every platform: no path separators, reserved or control @@ -604,23 +626,35 @@ impl PdfCraftApp { } } - /// Show Extract pages, its file name filled in with the active document's (#737). + /// Show Extract pages, its file name filled in with the active document's (#737) and no + /// page ranges typed, so it takes the selected pages until some are (#3593). pub fn open_extract_dialog(&mut self) { self.extract_draft.name = self.active_ids().and_then(|(_, id)| self.session.get(id)).map(|d| strip_pdf(&d.name).to_string()).unwrap_or_default(); + self.extract_draft.pages.clear(); self.dialog = Some(crate::Dialog::Extract); } - /// Copy the selected pages (or the current page) into a new unsaved document tab. + /// Copy the pages typed in Extract pages, else the selected pages (or the current page), into + /// a new unsaved document tab. pub fn extract_selection(&mut self) { // What's typed in a form field is part of the document (#166). if !self.commit_form_typing() { return; } let Some((i, id)) = self.active_ids() else { return }; - let pages = self.views[i].target_pages(); - let stem = self.session.get(id).map(|d| strip_pdf(&d.name).to_string()).unwrap_or_default(); let opts = self.extract_draft.clone(); + let pages = match opts.pages_to_extract(self.views[i].target_pages(), self.views[i].page_count()) { + Ok(pages) => pages, + Err(e) => { + self.notify_fmt("Couldn't extract pages: {e}", &[("e", &e)]); + return; + } + }; + // Deleting takes the pages in document order (typed ranges may run backwards). + let mut doomed = pages.clone(); + doomed.sort_unstable(); + let stem = self.session.get(id).map(|d| strip_pdf(&d.name).to_string()).unwrap_or_default(); // The name typed in the dialog (#737), else the document's. let chosen = file_stem_from_user(&opts.name); let renamed = !chosen.is_empty() && chosen != file_stem_from_user(&stem); @@ -650,7 +684,7 @@ impl PdfCraftApp { } let unchanged = app.session.get(id).map(|d| d.edit_generation()) == generation; if unchanged && app.active_ids().map(|(_, active)| active) == Some(id) { - app.apply_edit(pdfcraft_engine::Edit::DeletePages { pages }); + app.apply_edit(pdfcraft_engine::Edit::DeletePages { pages: doomed }); } else { app.notify_tr("The pages were extracted but not deleted, because the document changed while you were choosing a folder."); } @@ -677,7 +711,7 @@ impl PdfCraftApp { if opts.delete { // Back on the original document. self.active = Some(i); - self.apply_edit(pdfcraft_engine::Edit::DeletePages { pages }); + self.apply_edit(pdfcraft_engine::Edit::DeletePages { pages: doomed }); } } diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index e4ce56d7c5..ade218aaa6 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -7,8 +7,8 @@ use crate::support; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; -use pdfcraft_render::PageRenderer; +use egui_kittest::kittest::{NodeT, Queryable}; +use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use pdfcraft_ui_egui::PdfCraftApp; /// An `n`-page document with a proper xref table. @@ -255,6 +255,58 @@ fn extract_pages_saves_under_the_file_name_typed_in_the_dialog() { let _ = std::fs::remove_dir_all(&dir); } +/// #3593: Extract pages takes page ranges typed in its Pages field, in the order typed, each page +/// once; left empty, it takes the selected pages as before. A range it can't use is refused, with +/// the reason in the dialog and the Extract button off. +#[test] +fn extract_pages_takes_the_page_ranges_typed_in_the_dialog() { + let texts = |app: &PdfCraftApp, view: usize| { + let doc = app.session.get(app.views[view].id).unwrap(); + let mut r = PageRenderer::new(doc.bytes.clone(), Default::default()); + (0..r.page_count()) + .map(|p| { + let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, scale: 1.0, ..Default::default() }); + out.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default() + }) + .collect::>() + }; + let mut h = harness(&[6]); + h.state_mut().views[0].select_pages(&[1]); + assert!(h.state_mut().execute("page.extract")); + h.run_steps(2); + assert_eq!(h.state().extract_draft.pages, "", "opens on the selection"); + h.get_by_label("Pages"); + h.get_by_label_contains("1 page selected."); + // Ranges, a backward one and a repeat: in that order, each page once. + h.state_mut().extract_draft.pages = "5-6, 3-2, 6".into(); + h.run_steps(2); + h.get_by_label_contains("4 pages selected."); + h.state_mut().extract_selection(); + h.run_steps(2); + let s = h.state(); + assert_eq!(s.views.len(), 2, "the pages open in a new tab"); + assert_eq!(texts(s, 1), ["Page 5", "Page 6", "Page 3", "Page 2"]); + // A page that isn't there: the dialog says so, Extract is off and extracts nothing. + h.state_mut().active = Some(0); + h.state_mut().open_extract_dialog(); + h.state_mut().extract_draft.pages = "2, 9".into(); + h.run_steps(2); + assert!(h.get_by_label("Extract").accesskit_node().is_disabled(), "Extract is off"); + h.state_mut().extract_selection(); + h.run_steps(2); + assert_eq!(h.state().views.len(), 2, "nothing extracted"); + // Extract and delete takes the typed pages out of the document. + h.state_mut().active = Some(0); + h.state_mut().open_extract_dialog(); + h.state_mut().extract_draft.pages = "6, 1-2".into(); + h.state_mut().extract_draft.delete = true; + h.state_mut().extract_selection(); + h.run_steps(2); + let s = h.state(); + assert_eq!(texts(s, 2), ["Page 6", "Page 1", "Page 2"]); + assert_eq!(texts(s, 0), ["Page 3", "Page 4", "Page 5"]); +} + /// Left as the document's name (or emptied), the names are the ones Extract always used. #[test] fn extract_pages_keeps_the_usual_names_when_the_file_name_is_unchanged() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 362c2aa776..f0ea5933f1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2788,8 +2788,8 @@ milestone = "M4" status = "shipped" commands = ["page.extract"] tools = ["page_extract"] -evidence = ["crates/ui-egui/tests/it/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"] -notes = "Carries the PDF/X output intent and identification (#263) in a new XMP packet; the source XMP (dates, other schemas) is not copied." +evidence = ["crates/ui-egui/tests/it/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification", "crates/ui-egui/tests/it/pickers.rs::extract_pages_takes_the_page_ranges_typed_in_the_dialog"] +notes = "Carries the PDF/X output intent and identification (#263) in a new XMP packet; the source XMP (dates, other schemas) is not copied. The dialog's Pages field takes page ranges (\"1-5, 8, 11-13\", in the order typed, each page once) instead of the selection (#3593); page labels are not accepted there yet." [[feature]] id = "organize.extract-separate-files" From a08d974ac14bbaa37b34e5b1ccf728eb89b378ef Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:12:45 +0000 Subject: [PATCH 063/517] fix(cos): encrypt the /Contents of an annotation that carries a /ByteRange Saving with encryption left a dictionary's /Contents in clear whenever it had a string /Contents and an array /ByteRange, whatever else it was. A source file could add a /ByteRange to an ordinary annotation and its comment text was then written unencrypted. A dictionary with a /Subtype or a /Type other than Sig or DocTimeStamp is never a signature dictionary, so its /Contents is encrypted like any other string. --- crates/cos/src/security.rs | 13 ++++++++++--- crates/cos/tests/it/encryption.rs | 32 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index cd0d04a144..67d95aa7ac 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -88,10 +88,17 @@ fn named_crypt_filter(dict: &Dict) -> Option> { /// whose `/Contents` is never encrypted (ISO 32000-2 §7.6.2, §12.8.1): the signer writes a /// placeholder, hashes the file around it and patches the value in place, so encrypting it /// would make signing impossible. Identified as other readers do: by `/Type`, or by a string -/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). +/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). A signature dictionary +/// has no `/Subtype`; a dictionary with one (an annotation, whose `/Contents` is the comment +/// text) or with another `/Type` is never exempt, whatever else it carries. fn is_signature_dict(d: &Dict) -> bool { - matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")) - || (matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_)))) + if d.get(b"Subtype").is_some() { + return false; + } + match d.get(b"Type") { + Some(_) => matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")), + None => matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_))), + } } /// Decrypt (`decrypt = true`) or encrypt every string and stream in an indirect object, diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index e05161558d..dd2dcb1a41 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -279,6 +279,38 @@ fn signature_contents_stay_unencrypted() { } } +/// Only a signature dictionary keeps its `/Contents` in clear. An annotation that carries a +/// `/ByteRange` (or even `/Type /Sig`) is still an annotation, and its comment is encrypted. +#[test] +fn a_byte_range_does_not_exempt_an_annotation() { + for (ty, subtype) in [(Some("Annot"), "Text"), (None, "FreeText"), (Some("Sig"), "Text")] { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let mut note = pdfcraft_cos::Dict::new(); + if let Some(ty) = ty { + note.set(b"Type".to_vec(), Object::name(ty)); + } + note.set(b"Subtype".to_vec(), Object::name(subtype)); + note.set(b"Contents".to_vec(), PdfString::literal(b"Private note".to_vec())); + note.set(b"ByteRange".to_vec(), Object::Array(Vec::new())); + let note = doc.add(Object::Dict(note)); + doc.update_dict(doc.root().unwrap(), |c| c.set(b"Test".to_vec(), Object::Ref(note))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("Private note"), "{ty:?} {subtype}: comment encrypted"); + let back = Document::open(Arc::new(bytes)).unwrap(); + let note = back.get(back.root().unwrap()).as_dict().unwrap().reference(b"Test").unwrap(); + assert_eq!(back.get(note).as_dict().unwrap().get(b"Contents").unwrap().as_string().unwrap().bytes, b"Private note"); + } +} + #[test] fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() { // A V4 crypt filter's /Length counts bytes (≤ 32) or bits. A zero or negative value is From 60d2d1e3f21729befdd70cc734a3fca2ee09480c Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:12:57 +0000 Subject: [PATCH 064/517] fix(cos): put inherited crypt-filter selectors under the new protection A stream that named its own crypt filter (/Filter /Crypt with /DecodeParms /Name) kept that name when the document was given new password protection. The new /Encrypt defines only /StdCF, so the inherited name resolved to /Identity and the stream was written in clear into a file the user asked to protect. When saving with protection applied by set_encryption, a selector the new handler does not define is removed with its parameters and the stream follows the new policy. Saving with the original handler keeps the source's selectors. --- crates/cos/src/document.rs | 2 +- crates/cos/src/security.rs | 75 ++++++++++++++++++++++++++----- crates/cos/src/writer.rs | 5 ++- crates/cos/tests/it/encryption.rs | 44 +++++++++++++++++- 4 files changed, 113 insertions(+), 13 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index ae00234d0e..c3f6c6b748 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -858,7 +858,7 @@ impl Document { /// Decrypt a freshly parsed indirect object if the document is encrypted. fn decrypted(&self, id: ObjRef, o: Object) -> Object { match &self.security { - Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, true), + Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, crate::security::Pass::Decrypt), _ => o, } } diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index 67d95aa7ac..12d08b8dcf 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -101,36 +101,91 @@ fn is_signature_dict(d: &Dict) -> bool { } } -/// Decrypt (`decrypt = true`) or encrypt every string and stream in an indirect object, -/// except a signature dictionary's `/Contents` (see [`is_signature_dict`]). -pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, decrypt: bool) -> Object { +/// Remove the `/Crypt` entry from a stream's filter chain, with its decode parameters. +fn drop_crypt_filter(dict: &mut Dict) { + let i = match dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().position(|n| n.as_name() == Some(b"Crypt")), + Some(Object::Name(n)) if n.as_slice() == b"Crypt" => Some(0), + _ => None, + }; + let Some(i) = i else { return }; + let mut filters = match dict.get(b"Filter") { + Some(Object::Array(a)) => a.clone(), + _ => Vec::new(), + }; + if i < filters.len() { + filters.remove(i); + } + // A lone parameter dictionary belongs to the first filter (as `named_crypt_filter` reads it). + match dict.get(b"DecodeParms").cloned() { + Some(Object::Array(mut parms)) if i < parms.len() => { + parms.remove(i); + dict.set(b"DecodeParms".to_vec(), Object::Array(parms)); + } + Some(Object::Dict(_)) if i == 0 => { + dict.remove(b"DecodeParms"); + } + _ => {} + } + if filters.is_empty() { + dict.remove(b"Filter"); + if matches!(dict.get(b"DecodeParms"), Some(Object::Array(p)) if p.is_empty()) { + dict.remove(b"DecodeParms"); + } + } else { + dict.set(b"Filter".to_vec(), Object::Array(filters)); + } +} + +/// What [`transform`] does to an object. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Pass { + /// Decrypt, with the handler the document was opened with. + Decrypt, + /// Encrypt with the handler the document was opened with: streams keep their crypt filters. + Encrypt, + /// Encrypt with protection applied by `set_encryption`. A stream's crypt filter came from + /// the source's `/Encrypt`; unless the new one defines that name it is dropped, so the + /// stream follows the new policy instead of reading as `/Identity` and staying in clear. + EncryptReplacing, +} + +/// Decrypt or encrypt every string and stream in an indirect object, except a signature +/// dictionary's `/Contents` (see [`is_signature_dict`]). +pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, pass: Pass) -> Object { + let decrypt = pass == Pass::Decrypt; let strings = |s: &PdfString| { let bytes = if decrypt { h.decrypt_string(num, generation, &s.bytes) } else { h.encrypt_string(num, generation, &s.bytes) }; PdfString { bytes, hex: s.hex || !decrypt } }; - walk(o, &strings, h, num, generation, decrypt) + walk(o, &strings, h, num, generation, pass) } -fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, decrypt: bool) -> Object { +fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, pass: Pass) -> Object { + let decrypt = pass == Pass::Decrypt; match o { Object::String(s) => Object::String(strings(s)), - Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, decrypt)).collect()), + Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, pass)).collect()), Object::Dict(d) if is_signature_dict(d) => Object::Dict( d.iter() .map(|(k, v)| match (k.as_slice(), v) { (b"Contents", Object::String(_)) => (k.clone(), v.clone()), - _ => (k.clone(), walk(v, strings, h, num, generation, decrypt)), + _ => (k.clone(), walk(v, strings, h, num, generation, pass)), }) .collect(), ), - Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect()), + Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect()), Object::Stream(s) => { - let dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect(); + let mut dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect(); let ty = s.dict.name(b"Type"); if ty == Some(b"XRef") || (ty == Some(b"Metadata") && !h.encrypts_metadata()) { return Object::Stream(Stream { dict, raw: s.raw.clone() }); } - let named = named_crypt_filter(&s.dict); + let mut named = named_crypt_filter(&s.dict); + if pass == Pass::EncryptReplacing && named.as_deref().is_some_and(|n| !h.dict().crypt_filters.iter().any(|(f, _)| f == n)) { + drop_crypt_filter(&mut dict); + named = None; + } let kind = match (&named, ty) { (Some(n), _) => StreamKind::Named(n), (None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile, diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 3db0566ffe..b151399fef 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -132,7 +132,10 @@ fn write_stream(s: &Stream, out: &mut Vec) { /// except the `/Encrypt` dictionary itself (`source_num` is its number in the source document). fn prepared<'a>(doc: &Document, source_num: u32, num: u32, generation: u16, o: &'a Object) -> Cow<'a, Object> { match doc.output_security() { - (Some(h), encrypt_num) if Some(source_num) != encrypt_num => Cow::Owned(crate::security::transform(h, o, num, generation, false)), + (Some(h), encrypt_num) if Some(source_num) != encrypt_num => { + let pass = if doc.encryption_changed() { crate::security::Pass::EncryptReplacing } else { crate::security::Pass::Encrypt }; + Cow::Owned(crate::security::transform(h, o, num, generation, pass)) + } _ => Cow::Borrowed(o), } } diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index dd2dcb1a41..b0688b9304 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -9,11 +9,16 @@ use pdfcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, O const SECRET: &str = "BT /F1 18 Tf 20 100 Td (Secret page) Tj ET"; fn fixture() -> Vec { + fixture_with("") +} + +/// The fixture with `extra` added to the content stream's dictionary. +fn fixture_with(extra: &str) -> Vec { let objs = [ "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_string(), "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), - format!("<< /Length {} >>\nstream\n{SECRET}\nendstream", SECRET.len()), + format!("<< /Length {}{extra} >>\nstream\n{SECRET}\nendstream", SECRET.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), "<< /Type /Metadata /Subtype /XML /Length 22 >>\nstream\nmeta\nendstream".replace("22", "26"), "<< /Title (Quarterly numbers) >>".to_string(), @@ -311,6 +316,43 @@ fn a_byte_range_does_not_exempt_an_annotation() { } } +/// Protecting a document puts every stream under the new handler. A stream that selects a +/// crypt filter of its own loses the selector: the new `/Encrypt` defines only `/StdCF`, so an +/// inherited name (or `/Identity`) would leave the stream in clear. +#[test] +fn protecting_drops_inherited_crypt_filter_selectors() { + for selector in [" /Filter /Crypt /DecodeParms << /Name /MyCF >>", " /Filter [/Crypt] /DecodeParms [<< /Name /Identity >>]", " /Filter /Crypt"] { + let mut doc = Document::open(Arc::new(fixture_with(selector))).unwrap(); + assert_eq!(content(&doc), SECRET, "{selector}"); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("Secret page"), "{selector}: stream encrypted"); + assert_eq!(content(&Document::open(Arc::new(bytes.clone())).unwrap()), SECRET, "{selector}"); + assert!(hayro_syntax::Pdf::new(bytes).is_ok(), "{selector}"); + } + // Under the handler the document was opened with, `/Identity` is the author's choice (a + // stream meant to stay readable) and is kept. + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let pages = doc.get(catalog.reference(b"Pages").unwrap()).as_dict().cloned().unwrap(); + let page = doc.resolve(&pages.get(b"Kids").unwrap().as_array().unwrap()[0]).as_dict().cloned().unwrap(); + let contents = page.reference(b"Contents").unwrap(); + let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") }; + s.dict.set(b"Filter".to_vec(), Object::name("Crypt")); + doc.set(contents, Object::Stream(s)); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(String::from_utf8_lossy(&saved).contains("Secret page"), "written as the stream asks"); + assert_eq!(content(&Document::open(Arc::new(saved)).unwrap()), SECRET); +} + #[test] fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() { // A V4 crypt filter's /Length counts bytes (≤ 32) or bits. A zero or negative value is From 7fb6ab0f1fd731efe8eda5e04fd4e35b320ba20a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:13:03 +0000 Subject: [PATCH 065/517] test(cos): write the qpdf oracle files into a private new directory The qpdf oracle test wrote into temp_dir()/pdfcraft-enc-, reusing the directory if it existed and following any symlink inside it, so another local account could prepare it and have the test overwrite a file of the developer's. The directory now has an unpredictable name, is created by the test (mode 0700 on Unix) and the files are created new. --- crates/cos/tests/it/encryption.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index b0688b9304..8d03d22790 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -2,6 +2,7 @@ //! with our reader and with an independent one (hayro-syntax's decryption). Also editing //! encrypted documents incrementally, changing and removing passwords, and permissions. +use std::io::Write; use std::sync::Arc; use pdfcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, Object, PdfString, SaveOptions, write_full, write_incremental}; @@ -214,11 +215,10 @@ fn unencrypted_metadata_is_left_readable() { #[test] fn qpdf_agrees() { let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); - let dir = std::env::temp_dir().join(format!("pdfcraft-enc-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let dir = private_temp_dir("pdfcraft-enc"); for alg in ALL { let path = dir.join(format!("{alg:?}.pdf")); - std::fs::write(&path, protect(alg, "user", "owner", -1)).unwrap(); + std::fs::File::create_new(&path).and_then(|mut f| f.write_all(&protect(alg, "user", "owner", -1))).unwrap(); for (pw, expect) in [("user", "user password"), ("owner", "owner password")] { let out = qpdf.command().arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap(); let text = String::from_utf8_lossy(&out.stdout); @@ -230,6 +230,30 @@ fn qpdf_agrees() { let _ = std::fs::remove_dir_all(dir); } +/// A new directory for oracle files, private to this user: an unpredictable name (`RandomState` +/// is seeded by the operating system), created by this call or not at all. +fn private_temp_dir(prefix: &str) -> std::path::PathBuf { + use std::hash::{BuildHasher, Hasher}; + let mut name = std::collections::hash_map::RandomState::new().build_hasher(); + name.write_u32(std::process::id()); + let dir = std::env::temp_dir().join(format!("{prefix}-{:016x}", name.finish())); + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder.create(&dir).unwrap(); + dir +} + +/// Oracle files go in a directory this test created: another local account cannot have +/// prepared it (or symlinks inside it) in advance. +#[test] +fn oracle_files_go_in_a_fresh_directory() { + let (a, b) = (private_temp_dir("pdfcraft-enc-probe"), private_temp_dir("pdfcraft-enc-probe")); + assert_ne!(a, b); + assert_eq!(std::fs::read_dir(&a).unwrap().count(), 0); + let _ = (std::fs::remove_dir(a), std::fs::remove_dir(b)); +} + /// A signature dictionary's `/Contents` is never encrypted (§7.6.2): signers patch it in place /// after hashing the file around it. Its other strings, and the `/Contents` of anything that /// is not a signature dictionary, are encrypted as usual, and reading leaves it untouched. From b72c730a951352cd427c24d0b11cfba9a17b00e0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:13:36 +0000 Subject: [PATCH 066/517] fix(cos): give the protect example a fresh key on every run The example passed a constant seed to set_encryption. AES-256 derives the file key from the seed alone, so anyone could rebuild the key and read a file it protected without either password. The seed now comes from the operating system's randomness through RandomState, as in the engine. --- crates/cos/examples/protect.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/cos/examples/protect.rs b/crates/cos/examples/protect.rs index 6f27a60969..48498aefa8 100644 --- a/crates/cos/examples/protect.rs +++ b/crates/cos/examples/protect.rs @@ -1,4 +1,17 @@ //! Encrypt a PDF: `cargo run -p pdfcraft-cos --example protect in.pdf out.pdf rc4-40|rc4-128|aes-128|aes-256 USER OWNER` +/// 32 fresh bytes for the file key. `RandomState` is seeded by the operating system (as the +/// engine's `entropy` uses it), so every run encrypts with a different key. +fn seed() -> [u8; 32] { + use std::hash::{BuildHasher, Hasher}; + let mut out = [0u8; 32]; + for (i, chunk) in out.chunks_mut(8).enumerate() { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_usize(i); + chunk.copy_from_slice(&h.finish().to_le_bytes()); + } + out +} + fn main() { let a: Vec = std::env::args().skip(1).collect(); let [input, output, alg, user, owner] = &a[..] else { panic!("usage: protect in out alg user owner") }; @@ -15,7 +28,7 @@ fn main() { owner_password: owner, permissions: -1, encrypt_metadata: true, - seed: [1; 32], + seed: seed(), }) .unwrap(); std::fs::write(output, pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()).unwrap(); From faab42d4058ff70588d22a1ac82e7b2b27b375e3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:58 +0000 Subject: [PATCH 067/517] fix(sign): authorize an embedded OCSP responder by signature, not by name An OCSP response's signer counted as the issuer itself whenever its certificate carried the issuer's subject name. A response naming its responder by key hash could embed a certificate for any key with the issuer's name copied in, skip the delegation checks, and mark a valid certificate revoked. Whether the responder is the issuer now follows from how it was selected (the issuer's own name or key hash); every embedded certificate must be signed by the issuer and carry the OCSP-signing purpose. --- crates/sign/src/revocation.rs | 15 ++++++------ crates/sign/tests/it/crypto.rs | 43 ++++++++++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/crates/sign/src/revocation.rs b/crates/sign/src/revocation.rs index 8f9dac0cdc..b4a9d820f9 100644 --- a/crates/sign/src/revocation.rs +++ b/crates/sign/src/revocation.rs @@ -259,26 +259,27 @@ impl OcspResponse { /// response window. pub fn check(&self, cert: &Certificate, issuer: &Certificate, at: Time) -> RevocationStatus { // Who signed the response? The issuer identified by responderID, or a delegated, - // embedded responder identified the same way. - let responder: Option<&Certificate> = match &self.responder_id { + // embedded responder identified the same way. Which of the two it is comes from that + // choice, never from the embedded certificate's names, which anyone can copy. + let (responder, delegated): (Option<&Certificate>, bool) = match &self.responder_id { ResponderId::ByName(name) => { if *name == issuer.subject.raw { - Some(issuer) + (Some(issuer), false) } else { - self.responder_cert.as_ref().filter(|c| *name == c.subject.raw) + (self.responder_cert.as_ref().filter(|c| *name == c.subject.raw), true) } } ResponderId::ByKey(hash) => { if *hash == DigestAlg::Sha1.digest(&[&issuer.public_key.key_bits()]) { - Some(issuer) + (Some(issuer), false) } else { - self.responder_cert.as_ref().filter(|c| *hash == DigestAlg::Sha1.digest(&[&c.public_key.key_bits()])) + (self.responder_cert.as_ref().filter(|c| *hash == DigestAlg::Sha1.digest(&[&c.public_key.key_bits()])), true) } } }; let Some(responder) = responder else { return RevocationStatus::Unknown }; // A delegated responder must carry the OCSP-signing EKU and be issued by the issuer. - if responder.subject.raw != issuer.subject.raw { + if delegated { if responder.issuer.raw != issuer.subject.raw || !responder.signed_by(&issuer.public_key) { return RevocationStatus::Unknown; } diff --git a/crates/sign/tests/it/crypto.rs b/crates/sign/tests/it/crypto.rs index cd36c52774..193fd06c62 100644 --- a/crates/sign/tests/it/crypto.rs +++ b/crates/sign/tests/it/crypto.rs @@ -355,10 +355,19 @@ fn ocsp(issuer: &pkcs12::DigitalId, status: RevocationStatus, this: Time, next: }; let single = der::seq(&[&cert_id, &status, &this.encode(), &der::tlv(tag::ctx(0), &next.encode())]); let responder_id = der::tlv(tag::ctx(1), &issuer.certificate.subject.raw); - let tbs = der::seq(&[&responder_id, &this.encode(), &der::seq(&[&single])]); - let alg = issuer.key.signature_algorithm(DigestAlg::Sha256); - let sig = issuer.key.sign(DigestAlg::Sha256, &tbs).unwrap(); - let basic = der::seq(&[&tbs, &alg, &der::bit_string(&sig)]); + signed_ocsp(&issuer.key, &responder_id, None, &single, this) +} + +/// A successful OCSP response holding `single`, signed with `key` and naming its responder by +/// `responder_id`, with `certs` (the encoded `[0]` field) if given. +fn signed_ocsp(key: &pdfcraft_sign::PrivateKey, responder_id: &[u8], certs: Option<&[u8]>, single: &[u8], at: Time) -> Vec { + let tbs = der::seq(&[responder_id, &at.encode(), &der::seq(&[single])]); + let alg = key.signature_algorithm(DigestAlg::Sha256); + let sig = key.sign(DigestAlg::Sha256, &tbs).unwrap(); + let basic = match certs { + Some(c) => der::seq(&[&tbs, &alg, &der::bit_string(&sig), c]), + None => der::seq(&[&tbs, &alg, &der::bit_string(&sig)]), + }; let basic_type = der::oid("1.3.6.1.5.5.7.48.1.1"); der::seq(&[&der::int(0), &der::explicit(0, &der::seq(&[&basic_type, &der::octets(&basic)]))]) } @@ -465,6 +474,32 @@ fn ocsp_responses_verify_and_match_the_certificate() { assert!(OcspResponse::parse(&error).is_err()); } +/// An embedded responder certificate answers for an issuer only when the issuer signed it for +/// OCSP signing. One that merely copies the issuer's name, found by its key hash, does not. +#[test] +fn a_responder_that_copies_the_issuers_name_does_not_answer_for_it() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let issuer = &id.certificate; + let (this, next) = (mid_june(), Time { year: 2026, month: 7, day: 15, hour: 12, minute: 0, second: 0 }); + let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap(); + let impostor = Certificate::self_signed(&issuer.subject, &key, Time { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, 5, &[9]).unwrap(); + assert_eq!(impostor.subject.raw, issuer.subject.raw); + let cert_id = der::seq(&[ + &DigestAlg::Sha1.algorithm(), + &der::octets(&DigestAlg::Sha1.digest(&[&issuer.issuer.raw])), + &der::octets(&DigestAlg::Sha1.digest(&[&issuer.public_key.key_bits()])), + &der::uint(&issuer.serial), + ]); + let revoked = der::tlv(tag::ctx(1), &Time { year: 2026, month: 6, day: 2, hour: 9, minute: 0, second: 0 }.encode()); + let single = der::seq(&[&cert_id, &revoked, &this.encode(), &der::tlv(tag::ctx(0), &next.encode())]); + let by_key = der::tlv(tag::ctx(2), &der::octets(&DigestAlg::Sha1.digest(&[&impostor.public_key.key_bits()]))); + // RFC 6960's `[0] EXPLICIT SEQUENCE OF Certificate`, and the certificate directly in `[0]`. + for certs in [der::explicit(0, &der::seq(&[&impostor.raw])), der::explicit(0, &impostor.raw)] { + let forged = OcspResponse::parse(&signed_ocsp(&key, &by_key, Some(&certs), &single, this)).unwrap(); + assert_eq!(forged.check(issuer, issuer, mid_june()), RevocationStatus::Unknown); + } +} + // ── tolerance for the encodings found in old and unusual signers ─────────────────────────────── // RSA signatures over SHA-256("hello") by the key in rsa-aes.p12, made with OpenSSL 3 (see // tests/data/README.md): the standard DigestInfo, DigestInfo without the NULL parameter, the bare From fbada004d32d4fbb24638b2fe6eb27c0b9637f49 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:38:04 +0000 Subject: [PATCH 068/517] fix(sign): treat a changed trailer /Root after signing as not permitted Revision checks compared objects only. A later revision that pointed the trailer at another catalog already present in the signed file changed no object, so the signature stayed valid while the reader showed a different document. A /Root that differs from the signed revision's is now a disallowed change to the document structure. --- crates/sign/src/pdf.rs | 5 +++++ crates/sign/tests/it/pdf.rs | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 57c9f95fde..50f5d4a19f 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1286,6 +1286,11 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod disallowed.push(what); } } + // The trailer chooses the catalog. A later revision that points it at another catalog (one + // already in the signed file, so no object changes) shows another document. + if doc.root() != old.root() { + disallowed.push(kind::STRUCTURE); + } for num in doc.object_numbers() { if same_place(num) { continue; diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index e9db0b5700..29f61305f8 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -2471,3 +2471,27 @@ fn rewritten_page_resources_are_page_content_whatever_keys_they_carry() { s.modification ); } + +/// A later revision that only points the trailer at another catalog already in the signed file +/// shows a different document while every signed object is unchanged. That is not permitted. +#[test] +fn switching_the_trailer_to_another_signed_catalog_is_not_permitted() { + let base = fixture_with( + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", + &[ + b"<< /Type /Catalog /Pages 8 0 R /AcroForm << /Fields [6 0 R] >> >>", + b"<< /Type /Pages /Kids [9 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 8 0 R /MediaBox [0 0 300 300] /Contents 10 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>", + b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract void) Tj ET\nendstream", + ], + ); + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + let signed = pdfcraft_sign::sign(&open(&base), &id, &SignOptions { field: Some("Approval".into()), rect: None, ..opts() }).unwrap(); + let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Valid, "{:?}", s.details); + let switched = edit_after(&signed, |doc| doc.trailer_mut().set(b"Root".to_vec(), oref(7))); + let s = signatures(&open(&switched), &switched, &trust).into_iter().find(|s| s.signed).expect("the signature is still listed"); + assert!(matches!(s.modification, Modification::Disallowed(_)), "{:?}", s.modification); + assert_eq!(s.status, Status::Invalid, "{:?}", s.details); +} From d9cb7c2fe7e48708a65b4fbd8ef0b87245d42e8f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:38:19 +0000 Subject: [PATCH 069/517] fix(sign): don't build chains through a CA with unprocessed critical extensions Chain building accepted an intermediate CA without looking at its critical extensions, which were checked only on the signer's certificate. A subordinate CA limited by critical name constraints could vouch for a signer outside its namespace, because PdfCraft does not enforce those constraints. A CA certificate (other than a self-signed root, whose limits are the trust store's business) with a critical extension PdfCraft doesn't process is refused as an issuer, and the reason is reported. --- crates/sign/src/pdf.rs | 1 + crates/sign/src/x509.rs | 7 ++++++- crates/sign/tests/it/chain.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 50f5d4a19f..ef2715f831 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -378,6 +378,7 @@ pub const WORDINGS: &[&str] = &[ "The signer's certificate has critical extensions PdfCraft does not recognize ({}).", // Why a certificate that matched the issuer was not used to build the chain. "The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}.", + "The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}.", "The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}.", "The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}.", "The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates.", diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 53c80e2695..56ec8fbddc 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -492,7 +492,8 @@ pub fn subject_of(raw: &[u8]) -> Option { } /// The chain from `leaf` up through `pool`, as far as issuers can be found and may issue: each -/// issuer's key verifies the certificate below it, it is a CA allowed to sign certificates, its +/// issuer's key verifies the certificate below it, it is a CA allowed to sign certificates, it +/// has no critical extension PdfCraft doesn't process (unless it is self-signed), its /// `pathLenConstraint` allows the CA certificates below it, and (when `at` is given) it was valid /// then. Stops at a self-signed certificate. Certificates embedded in a document are in `pool` /// too, so an ordinary end-entity certificate must never be accepted as an issuer. @@ -522,6 +523,10 @@ pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at: // two names where it needs them. let why = if !c.may_issue() { Some("The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}.") + } else if !c.unknown_critical.is_empty() && !c.is_self_signed() { + // What such an extension limits (name constraints, policy constraints) would + // go unchecked. A self-signed root's extensions are the trust store's business. + Some("The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}.") } else if c.path_len.is_some_and(|n| below > n as usize) { Some("The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}.") } else if at.is_some_and(|t| !c.valid_at(t)) { diff --git a/crates/sign/tests/it/chain.rs b/crates/sign/tests/it/chain.rs index 5defd859d5..2b6b5f85a3 100644 --- a/crates/sign/tests/it/chain.rs +++ b/crates/sign/tests/it/chain.rs @@ -340,3 +340,23 @@ fn an_unreadable_extended_key_usage_allows_no_purpose() { verdict(signer_with("Good EKU", &r, &[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["2.5.29.37.0"])]), vec![], &r.cert); assert_eq!(status, Status::Valid, "{details:?}"); } + +/// A CA certificate with a critical extension PdfCraft does not enforce (here name constraints) +/// vouches for nothing: what that extension limits would go unchecked. A self-signed root's +/// extensions are the trust store's business, so such a root still anchors. +#[test] +fn an_issuer_with_an_unprocessed_critical_extension_does_not_vouch() { + let name_constraints = ext("2.5.29.30", &der::seq(&[])); + let r = root(); + let constrained = issue("Name Constrained CA", Some(&r), FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints.clone()]); + let l = end_entity("Outside Its Names", &constrained); + let pool = [constrained.cert.clone(), r.cert.clone()]; + let (chain, why) = build_chain_noted(&l.cert, &pool, None); + assert_eq!(names(&chain), ["Outside Its Names"]); + assert!(why.is_some_and(|w| w.message.contains("critical extensions")), "the reason is reported"); + let constrained_root = issue("Name Constrained Root", None, FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints]); + let i = ca("Under Constrained Root", &constrained_root, None); + let l = end_entity("Leaf", &i); + let pool = [i.cert.clone(), constrained_root.cert.clone()]; + assert_eq!(names(&build_chain(&l.cert, &pool, None)), ["Leaf", "Under Constrained Root", "Name Constrained Root"]); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index db00c69dd6..1d121c59bc 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2359,6 +2359,7 @@ action wizard Start Iniciar The signer's certificate has critical extensions PdfCraft does not recognize ({}). El certificado del firmante tiene extensiones críticas que PdfCraft no reconoce ({}). # Why a certificate that matched the issuer was not used to build the chain. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. El certificado de {} no es un certificado de CA que pueda emitir certificados, así que no se usa para avalar a {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. El certificado de {} tiene extensiones críticas que PdfCraft no reconoce, así que no se usa para avalar a {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. El certificado de {} no admite tantos certificados de CA por debajo (longitud de ruta), así que no se usa para avalar a {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. El certificado de {} no era válido en el momento de la firma, así que no se usa para avalar a {}. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. La identidad del firmante es desconocida porque no está en tu lista de certificados de confianza y ninguno de los certificados superiores es de confianza. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cd381b5393..8176e498bd 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2503,6 +2503,7 @@ animation effects Off Wyłączone The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Certyfikat podpisującego nie jest wystawiony do podpisywania dokumentów (jego rozszerzone użycie klucza nie obejmuje podpisywania dokumentów). The signer's certificate has critical extensions PdfCraft does not recognize ({}). Certyfikat podpisującego ma rozszerzenia krytyczne, których PdfCraft nie rozpoznaje ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Certyfikat podmiotu {} nie jest certyfikatem urzędu certyfikacji, który może wystawiać certyfikaty, dlatego nie poświadcza certyfikatu {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Certyfikat podmiotu {} ma rozszerzenia krytyczne, których PdfCraft nie rozpoznaje, dlatego nie poświadcza certyfikatu {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Certyfikat podmiotu {} nie dopuszcza tylu certyfikatów urzędów certyfikacji poniżej siebie (długość ścieżki), dlatego nie poświadcza certyfikatu {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Certyfikat podmiotu {} nie był ważny w chwili podpisania, dlatego nie poświadcza certyfikatu {}. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Tożsamość podpisującego jest nieznana, ponieważ jego certyfikatu nie ma na liście zaufanych certyfikatów i żaden z certyfikatów nadrzędnych nie jest zaufany. From 4e9d8f9b3b8f9f6403bdcb9145d8290ab9a58537 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:38:19 +0000 Subject: [PATCH 070/517] fix(sign): require the time-stamping purpose for trusted timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A timestamp counted as trusted time whenever its signer's certificate chained to a trusted certificate, whatever the certificate was issued for. Any key from a trusted CA could then vouch for an arbitrary time. Trusted time, for a document timestamp and for a signature's embedded one, now also needs a certificate whose extended key usage includes timeStamping, whose key usage allows signing, and which has no critical extension PdfCraft doesn't process (RFC 3161 §2.3). The test timestamp authority gets such a certificate. --- crates/sign/src/pdf.rs | 23 +++++++---- crates/sign/src/x509.rs | 15 ++++++++ crates/sign/tests/it/pdf.rs | 70 +++++++++++++++++++++++++++++----- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + 5 files changed, 94 insertions(+), 16 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index ef2715f831..82a801dee8 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -401,6 +401,7 @@ pub const WORDINGS: &[&str] = &[ "The timestamp token could not be read ({}).", "The document has been altered or corrupted since the timestamp was applied.", "The timestamp authority's certificate was not valid at the time of timestamping.", + "The timestamp authority's certificate is not issued for time stamping.", "This document has not been modified since this timestamp was applied.", "The document has been modified since this timestamp was applied, but the changes are permitted ({}).", "The document has been altered since this timestamp was applied in ways it does not permit ({}).", @@ -771,13 +772,16 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf // own claimed time, as without a timestamp. let mut pool = crate::timestamp::token_certs(raw); pool.extend(trust.certs.iter().cloned()); - let trusted_tsa = t - .signer_certificate() - .is_some_and(|c| c.valid_at(t.gen_time) && build_chain(&c, &pool, Some(t.gen_time)).iter().any(|x| trust.trusts(x))); - if trusted_tsa { + let tsa = t.signer_certificate(); + let trusted_tsa = tsa + .as_ref() + .is_some_and(|c| c.valid_at(t.gen_time) && build_chain(c, &pool, Some(t.gen_time)).iter().any(|x| trust.trusts(x))); + if !trusted_tsa { + unverified_time = Some(t.gen_time); + } else if tsa.as_ref().is_some_and(Certificate::may_timestamp) { info.timestamp_time = Some(t.gen_time); } else { - unverified_time = Some(t.gen_time); + info.details.push(Detail::plain("The timestamp authority's certificate is not issued for time stamping.")); } } } @@ -1085,6 +1089,10 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig info.status = Status::Unknown; info.details.push(Detail::plain("The timestamp authority's certificate was not valid at the time of timestamping.")); } + if !cert.may_timestamp() { + info.status = Status::Unknown; + info.details.push(Detail::plain("The timestamp authority's certificate is not issued for time stamping.")); + } } info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) { Modification::None @@ -1112,9 +1120,10 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig pool.extend(trust.certs.iter().cloned()); // A trusted authority whose certificate was not valid at the time it stamped proves // nothing either (its detail is already recorded above): never upgrade that to Valid. - let in_validity = token.signer_certificate().is_some_and(|c| c.valid_at(token.gen_time)); + // Nor does one whose certificate is not issued for time stamping. + let may_stamp = token.signer_certificate().is_some_and(|c| c.valid_at(token.gen_time) && c.may_timestamp()); let trusted_tsa = token.signer_certificate().is_some_and(|c| build_chain(&c, &pool, Some(token.gen_time)).iter().any(|x| trust.trusts(x))); - if trusted_tsa && in_validity { + if trusted_tsa && may_stamp { info.status = Status::Valid; info.details.push(Detail::plain("The timestamp token is valid and its authority is trusted.")); } else if trusted_tsa { diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 56ec8fbddc..5dd03d086d 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -165,6 +165,9 @@ const PROCESSED_EXTENSIONS: [&str; 11] = [ "1.3.6.1.5.5.7.1.3", ]; +/// id-kp-timeStamping (RFC 5280 §4.2.1.12), required of a timestamp authority (RFC 3161 §2.3). +const EKU_TIME_STAMPING: &str = "1.3.6.1.5.5.7.3.8"; + /// Extended key usages that allow signing documents: anyExtendedKeyUsage, emailProtection, /// codeSigning, documentSigning (RFC 9336), Adobe Authentic Documents and Microsoft document /// signing. @@ -384,6 +387,18 @@ impl Certificate { } } + /// Whether this certificate may stamp trusted time (RFC 3161 §2.3): its extended key usage + /// includes timeStamping, its key usage (if any) allows digital signatures or + /// non-repudiation, and it has no critical extension PdfCraft doesn't process. A certificate + /// issued for another purpose proves who signed, not when. + pub fn may_timestamp(&self) -> bool { + /// keyUsage bits 0 (digitalSignature) and 1 (nonRepudiation). + const SIGNING_BITS: u16 = 0b11; + self.extended_key_usage.as_ref().is_some_and(|e| e.iter().any(|o| o == EKU_TIME_STAMPING)) + && self.key_usage.is_none_or(|u| u & SIGNING_BITS != 0) + && self.unknown_critical.is_empty() + } + /// [`signing_problem`](Self::signing_problem) as a clause that follows the certificate's /// name ("it is not issued for signing documents (…)"), for lists of digital IDs. pub fn signing_problem_clause(&self) -> Option { diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 29f61305f8..f7dc33f685 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -384,6 +384,38 @@ struct TestTsa { time: Time, } +/// A timestamp authority's digital ID: a self-signed certificate valid from October 2026 for +/// ten years, whose critical extended key usage is time stamping alone, as RFC 3161 requires. +fn tsa_id() -> DigitalId { + tsa_id_with(&["1.3.6.1.5.5.7.3.8"]) +} + +/// [`tsa_id`] with these extended key usages. +fn tsa_id_with(ekus: &[&str]) -> DigitalId { + let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap(); + let name = Name::build("Test TSA", "", "PdfCraft Tests", "", "US"); + let alg = key.preferred_digest(); + let sig_alg = key.signature_algorithm(alg); + let from = Time { year: 2026, month: 10, day: 2, hour: 0, minute: 0, second: 0 }; + let until = Time { year: 2036, ..from }; + let oids: Vec> = ekus.iter().map(|o| der::oid(o)).collect(); + let oids: Vec<&[u8]> = oids.iter().map(Vec::as_slice).collect(); + let eku = der::seq(&[&der::oid("2.5.29.37"), &der::boolean(true), &der::octets(&der::seq(&oids))]); + let tbs = der::seq(&[ + &der::explicit(0, &der::int(2)), + &der::uint(&[0x31]), + &sig_alg, + &name.raw, + &der::seq(&[&from.encode(), &until.encode()]), + &name.raw, + &key.public_key().spki(), + &der::explicit(3, &der::seq(&[&eku])), + ]); + let signature = key.sign(alg, &tbs).unwrap(); + let certificate = Certificate::parse(&der::seq(&[&tbs, &sig_alg, &der::bit_string(&signature)])).unwrap(); + DigitalId { key, certificate, chain: Vec::new(), friendly_name: None } +} + impl TimestampAuthority for TestTsa { fn timestamp(&self, request: &[u8]) -> Result, SignError> { let q = pdfcraft_sign::timestamp::parse_request(request)?; @@ -404,7 +436,7 @@ impl TimestampAuthority for TestTsa { fn signing_with_a_timestamp_embeds_a_verified_rfc3161_token() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap(); @@ -443,7 +475,7 @@ fn a_malformed_timestamp_response_fails_signing_without_a_file() { #[test] fn a_document_timestamp_covers_the_file_and_validates() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); @@ -480,7 +512,7 @@ fn a_document_timestamp_covers_the_file_and_validates() { #[test] fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let mut source = open(&fixture()); @@ -559,7 +591,7 @@ fn sign_then_ltv_then_timestamp_makes_a_b_lta_file() { use pdfcraft_sign::dss::{self, Evidence}; let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); @@ -1532,7 +1564,7 @@ fn an_untrusted_timestamp_does_not_set_the_validation_time() { // The TSA claims 2027 (inside the signer's validity) but is not trusted; the signer's own // clock says 2040, after the certificate expired. let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, }; let late = SignOptions { date: "D:20400101120000Z".into(), ..opts() }; @@ -1754,7 +1786,7 @@ fn fixture_timestamped_in_a_field(tsa: &TestTsa) -> Vec { #[test] fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let stamped = fixture_timestamped_in_a_field(&tsa); @@ -1786,7 +1818,7 @@ fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() { #[test] fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), // Before the TSA certificate's validity (it starts in October 2026). time: Time { year: 2020, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, }; @@ -1802,7 +1834,7 @@ fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() { #[test] fn a_standalone_timestamp_typed_sig_by_an_older_writer_is_still_a_timestamp() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); @@ -2036,7 +2068,7 @@ fn signing_waits_for_a_pending_security_change_to_be_saved() { #[test] fn encrypted_documents_take_a_document_timestamp() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let base = protected(pdfcraft_cos::Algorithm::Aes128, FILL_AND_SIGN_ONLY); @@ -2495,3 +2527,23 @@ fn switching_the_trailer_to_another_signed_catalog_is_not_permitted() { assert!(matches!(s.modification, Modification::Disallowed(_)), "{:?}", s.modification); assert_eq!(s.status, Status::Invalid, "{:?}", s.details); } + +/// Only a certificate issued for time stamping makes trusted time. A trusted certificate issued +/// for something else (here e-mail) stamps nothing trusted: neither a document timestamp nor +/// a signature's embedded one. +#[test] +fn a_timestamp_from_a_certificate_not_issued_for_time_stamping_is_not_trusted_time() { + let tsa = TestTsa { id: tsa_id_with(&["1.3.6.1.5.5.7.3.4"]), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; + let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); + let trust = TrustStore { certs: vec![tsa.id.certificate.clone()] }; + let s = signatures(&open(&stamped), &stamped, &trust).into_iter().find(|s| s.doc_timestamp).unwrap(); + assert_eq!(s.status, Status::Unknown, "{:?}", s.details); + assert!(s.details.iter().any(|d| d.message.contains("not issued for time stamping")), "{:?}", s.details); + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone(), tsa.id.certificate.clone()] }; + let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap(); + assert!(s.timestamp); + assert_eq!(s.timestamp_time, None, "{:?}", s.details); + assert!(s.details.iter().any(|d| d.message.contains("not issued for time stamping")), "{:?}", s.details); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1d121c59bc..73b0ffbf18 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2379,6 +2379,7 @@ action wizard Start Iniciar The timestamp token could not be read ({}). No se ha podido leer el sello de tiempo ({}). The document has been altered or corrupted since the timestamp was applied. El documento se ha alterado o se ha dañado desde que se aplicó el sello de tiempo. The timestamp authority's certificate was not valid at the time of timestamping. El certificado de la autoridad de sellado de tiempo no era válido en el momento del sellado. + The timestamp authority's certificate is not issued for time stamping. El certificado de la autoridad de sellado de tiempo no está emitido para sellar el tiempo. This document has not been modified since this timestamp was applied. Este documento no se ha modificado desde que se aplicó este sello de tiempo. The document has been modified since this timestamp was applied, but the changes are permitted ({}). El documento se ha modificado desde que se aplicó este sello de tiempo, pero son cambios que permite ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). El documento se ha alterado desde que se aplicó este sello de tiempo de maneras que no permite ({}). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8176e498bd..6836f3e0a3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2523,6 +2523,7 @@ animation effects Off Wyłączone The timestamp token could not be read ({}). Nie udało się odczytać tokenu znacznika czasu ({}). The document has been altered or corrupted since the timestamp was applied. Dokument został zmieniony lub uszkodzony po dodaniu znacznika czasu. The timestamp authority's certificate was not valid at the time of timestamping. Certyfikat urzędu znacznika czasu nie był ważny w chwili oznaczania czasem. + The timestamp authority's certificate is not issued for time stamping. Certyfikat urzędu znakowania czasem nie jest wystawiony do znakowania czasem. This document has not been modified since this timestamp was applied. Dokument nie był modyfikowany od dodania tego znacznika czasu. The document has been modified since this timestamp was applied, but the changes are permitted ({}). Dokument zmodyfikowano po dodaniu tego znacznika czasu, ale zmiany są dozwolone ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). Dokument zmieniono po dodaniu tego znacznika czasu w sposób, na który znacznik nie pozwala ({}). From 3e180944267020b471de53f87c09cafb3b9fe474 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:35:35 +0000 Subject: [PATCH 071/517] i18n(ui): carry the two new signature wordings in the catalogues that must cover them Czech and Vietnamese get translations; Catalan, Finnish, Lithuanian, Brazilian Portuguese and Romanian carry the English until translated. --- crates/ui-egui/src/i18n/ca.tsv | 2 ++ crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/fi.tsv | 2 ++ crates/ui-egui/src/i18n/lt.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ro.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 2 ++ 7 files changed, 14 insertions(+) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9cad6ad5ec..dd2f497b84 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2283,6 +2283,7 @@ action wizard Start Inicia The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). El certificat del signant no s'ha emès per signar documents (el seu ús estès de clau no inclou el propòsit de signatura de documents). The signer's certificate has critical extensions PdfCraft does not recognize ({}). El certificat del signant té extensions crítiques que PdfCraft no reconeix ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. El certificat de {} no és un certificat de CA que pugui emetre certificats, de manera que no s'utilitza per avalar {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. El certificat de {} no permet tants certificats de CA per sota seu (longitud de camí), de manera que no s'utilitza per avalar {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. El certificat de {} no era vàlid en el moment de signar, de manera que no s'utilitza per avalar {}. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. La identitat del signant és desconeguda perquè no s'ha inclòs a la llista de certificats de confiança i cap dels seus certificats pare és un certificat de confiança. @@ -2302,6 +2303,7 @@ action wizard Start Inicia The timestamp token could not be read ({}). No s'ha pogut llegir el testimoni de marca de temps ({}). The document has been altered or corrupted since the timestamp was applied. El document ha estat alterat o s'ha corromput des que es va aplicar la marca de temps. The timestamp authority's certificate was not valid at the time of timestamping. El certificat de l'autoritat de marca de temps no era vàlid en el moment de marcar el temps. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. This document has not been modified since this timestamp was applied. Aquest document no s'ha modificat des que es va aplicar aquesta marca de temps. The document has been modified since this timestamp was applied, but the changes are permitted ({}). El document s'ha modificat des que es va aplicar aquesta marca de temps, però els canvis són permesos ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). El document ha estat alterat des que es va aplicar aquesta marca de temps d'una manera que no permet ({}). diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4e736bbf46..66cac29aaf 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2615,6 +2615,7 @@ animation effects Off Vypnuto The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo podpisu RSA nemá parametr NULL (starší podepisovací programy jej tak zapisují); přesto byl načten. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Certifikát {} nepovoluje pod sebou tolik certifikátů CA (délka cesty), proto se nepoužije k ručení za {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Certifikát {} není certifikát CA, který smí vydávat certifikáty, proto se nepoužije k ručení za {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Certifikát {} má kritická rozšíření, která PdfCraft nezná, proto se nepoužije k ručení za {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Certifikát {} nebyl v době podpisu platný, proto se nepoužije k ručení za {}. The checker found problems which may prevent the document from being fully accessible. Kontrola našla problémy, které mohou bránit plné přístupnosti dokumentu. The document has been altered or corrupted since the signature was applied. Dokument byl od připojení podpisu změněn nebo poškozen. @@ -2657,6 +2658,7 @@ animation effects Off Vypnuto The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Identita podepisující osoby je neznámá, protože není ve vašem seznamu důvěryhodných certifikátů a žádný z jejích nadřazených certifikátů není důvěryhodný. The signer's identity is valid. Identita podepisující osoby je platná. The timestamp authority's certificate was not valid at the time of timestamping. Certifikát autority časových razítek nebyl v době vystavení razítka platný. + The timestamp authority's certificate is not issued for time stamping. Certifikát autority časových razítek není vystaven pro vydávání časových razítek. The timestamp contents are not hexadecimal. Obsah časového razítka není šestnáctkový. The timestamp has no valid byte range. Časové razítko nemá platný rozsah bajtů. The timestamp token could not be read ({}). Časové razítko nebylo možné načíst ({}). diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ddc299823..efcf3e3c6e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2496,6 +2496,7 @@ animation effects Off Ei käytössä The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Allekirjoittajan varmennetta ei ole myönnetty asiakirjojen allekirjoittamiseen (sen laajennettu avaimen käyttö ei sisällä asiakirjojen allekirjoitusta). The signer's certificate has critical extensions PdfCraft does not recognize ({}). Allekirjoittajan varmenteessa on kriittisiä laajennuksia, joita PdfCraft ei tunnista ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Kohteen {} varmenne ei ole varmenteita myöntävä CA-varmenne, joten sitä ei käytetä kohteen {} vahvistamiseen. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Kohteen {} varmenne ei salli näin montaa alempaa CA-varmennetta (polun pituus), joten sitä ei käytetä kohteen {} vahvistamiseen. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Kohteen {} varmenne ei ollut voimassa allekirjoitushetkellä, joten sitä ei käytetä kohteen {} vahvistamiseen. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Allekirjoittajan henkilöllisyys on tuntematon, koska sitä ei ole lisätty luotettujen varmenteiden luetteloon eikä mikään sen ylemmistä varmenteista ole luotettu varmenne. @@ -2515,6 +2516,7 @@ animation effects Off Ei käytössä The timestamp token could not be read ({}). Aikaleimatunnistetta ei voitu lukea ({}). The document has been altered or corrupted since the timestamp was applied. Asiakirjaa on muutettu tai se on vioittunut aikaleiman lisäämisen jälkeen. The timestamp authority's certificate was not valid at the time of timestamping. Aikaleiman myöntäjän varmenne ei ollut voimassa aikaleimaushetkellä. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. This document has not been modified since this timestamp was applied. Asiakirjaa ei ole muokattu tämän aikaleiman lisäämisen jälkeen. The document has been modified since this timestamp was applied, but the changes are permitted ({}). Asiakirjaa on muokattu tämän aikaleiman lisäämisen jälkeen, mutta muutokset ovat sallittuja ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). Asiakirjaa on muutettu tämän aikaleiman lisäämisen jälkeen tavoilla, joita se ei salli ({}). diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 0e542211f4..e2476f1963 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -1735,6 +1735,7 @@ The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. RSA parašo „DigestInfo“ neturi parametro NULL (taip įrašo senesnės pasirašymo programos); jis vis tiek perskaitytas. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Subjekto {} sertifikatas neleidžia tiek žemesnio lygio sertifikavimo įstaigų sertifikatų (grandinės ilgio riba), todėl juo nepatvirtinamas {} sertifikatas. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Subjekto {} sertifikatas nėra sertifikavimo įstaigos sertifikatas, leidžiantis išduoti sertifikatus, todėl juo nepatvirtinamas {} sertifikatas. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Subjekto {} sertifikatas pasirašymo metu negaliojo, todėl juo nepatvirtinamas {} sertifikatas. The checker found problems which may prevent the document from being fully accessible. Tikrintuvas aptiko problemų, dėl kurių dokumentas gali būti nevisiškai pritaikytas prieinamumui. The document has been altered or corrupted since the signature was applied. Po pasirašymo dokumentas buvo pakeistas arba pažeistas. @@ -1783,6 +1784,7 @@ The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Pasirašiusiojo tapatybė nežinoma, nes jo sertifikato nėra patikimų sertifikatų sąraše ir nė vienas aukštesnio lygio sertifikatas nėra patikimas. The signer's identity is valid. Pasirašiusiojo tapatybė patvirtinta. The timestamp authority's certificate was not valid at the time of timestamping. Laiko žymą išdavusios įstaigos sertifikatas žymos sukūrimo metu negaliojo. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. The timestamp contents are not hexadecimal. Laiko žymos turinys nėra šešioliktainis. The timestamp has no valid byte range. Laiko žyma neturi tinkamo baitų intervalo. The timestamp token could not be read ({}). Nepavyko perskaityti laiko žymos žetono ({}). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8fe72f99b2..dcbebae958 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2634,6 +2634,7 @@ animation effects Off Desativados The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. O DigestInfo da assinatura RSA não tem o parâmetro NULL (assinantes mais antigos o incluem); mesmo assim, ele foi lido. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. O certificado de {} não permite essa quantidade de certificados de AC abaixo dele (comprimento do caminho), então não é usado para atestar a validade de {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. O certificado de {} não é um certificado de AC autorizado a emitir certificados, então não é usado para atestar a validade de {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. O certificado de {} não era válido no momento da assinatura, então não é usado para atestar a validade de {}. The checker found problems which may prevent the document from being fully accessible. A verificação encontrou problemas que podem impedir que o documento seja totalmente acessível. The document has been altered or corrupted since the signature was applied. O documento foi alterado ou corrompido desde que a assinatura foi aplicada. @@ -2680,6 +2681,7 @@ animation effects Off Desativados The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. A identidade do signatário é desconhecida porque não foi incluída na sua lista de certificados confiáveis e nenhum dos certificados na cadeia acima dela é confiável. The signer's identity is valid. A identidade do signatário é válida. The timestamp authority's certificate was not valid at the time of timestamping. O certificado da autoridade de carimbo de data e hora não era válido no momento da emissão do carimbo. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. The timestamp contents are not hexadecimal. O conteúdo do carimbo de data e hora não está em hexadecimal. The timestamp has no valid byte range. O carimbo de data e hora não tem um intervalo de bytes válido. The timestamp token could not be read ({}). Não foi possível ler o token de carimbo de data e hora ({}). diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 5a8025dc80..0003e38519 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -1684,6 +1684,7 @@ The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo din semnătura RSA nu are parametrul NULL (semnatarii mai vechi îl scriu așa); a fost citită oricum. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Certificatul lui {} nu permite atâtea certificate CA sub el (lungimea căii), așa că nu este folosit pentru a garanta pentru {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Certificatul lui {} nu este un certificat CA care poate emite certificate, așa că nu este folosit pentru a garanta pentru {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Certificatul lui {} nu era valabil în momentul semnării, așa că nu este folosit pentru a garanta pentru {}. The checker found problems which may prevent the document from being fully accessible. Verificatorul a găsit probleme care pot împiedica accesibilitatea completă a documentului. The document has been altered or corrupted since the signature was applied. Documentul a fost modificat sau deteriorat după aplicarea semnăturii. @@ -1729,6 +1730,7 @@ The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Identitatea semnatarului este necunoscută, deoarece nu a fost inclusă în lista certificatelor de încredere și niciunul dintre certificatele părinte nu este de încredere. The signer's identity is valid. Identitatea semnatarului este validă. The timestamp authority's certificate was not valid at the time of timestamping. Certificatul autorității de marcare temporală nu era valabil în momentul marcării. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. The timestamp contents are not hexadecimal. Conținutul marcajului temporal nu este hexazecimal. The timestamp has no valid byte range. Marcajul temporal nu are un interval de octeți valid. The timestamp token could not be read ({}). Tokenul de marcaj temporal nu a putut fi citit ({}). diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bb20dcc5b9..87abe51290 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1724,6 +1724,7 @@ theme Appearance Giao diện The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo của chữ ký RSA không có tham số NULL (các trình ký cũ ghi như vậy); nó vẫn được đọc. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Chứng chỉ của {} không cho phép có nhiều chứng chỉ CA ở dưới nó như vậy (độ dài đường dẫn), nên nó không được dùng để bảo chứng cho {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Chứng chỉ của {} không phải là chứng chỉ CA được phép cấp chứng chỉ, nên nó không được dùng để bảo chứng cho {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Chứng chỉ của {} có các phần mở rộng quan trọng mà PdfCraft không nhận ra, nên nó không được dùng để bảo chứng cho {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Chứng chỉ của {} không hợp lệ tại thời điểm ký, nên nó không được dùng để bảo chứng cho {}. The checker found problems which may prevent the document from being fully accessible. Trình kiểm tra tìm thấy các vấn đề có thể khiến tài liệu không thể tiếp cận đầy đủ. The document has been altered or corrupted since the signature was applied. Tài liệu đã bị thay đổi hoặc hỏng kể từ khi chữ ký được áp dụng. @@ -1772,6 +1773,7 @@ theme Appearance Giao diện The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Danh tính của người ký không rõ vì nó chưa được đưa vào danh sách chứng chỉ tin cậy của bạn và không chứng chỉ cấp trên nào của nó là chứng chỉ tin cậy. The signer's identity is valid. Danh tính của người ký hợp lệ. The timestamp authority's certificate was not valid at the time of timestamping. Chứng chỉ của cơ quan cấp dấu thời gian không hợp lệ tại thời điểm đóng dấu thời gian. + The timestamp authority's certificate is not issued for time stamping. Chứng chỉ của cơ quan cấp dấu thời gian không được cấp cho mục đích đóng dấu thời gian. The timestamp contents are not hexadecimal. Nội dung dấu thời gian không phải là thập lục phân. The timestamp has no valid byte range. Dấu thời gian không có dải byte hợp lệ. The timestamp token could not be read ({}). Không thể đọc mã thông báo dấu thời gian ({}). From 7c4731234f0e5a819461c98400676595e2a07b07 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:36:37 +0000 Subject: [PATCH 072/517] style(sign): rustfmt the new tests --- crates/sign/tests/it/chain.rs | 6 +++-- crates/sign/tests/it/crypto.rs | 3 ++- crates/sign/tests/it/pdf.rs | 40 +++++++--------------------------- 3 files changed, 14 insertions(+), 35 deletions(-) diff --git a/crates/sign/tests/it/chain.rs b/crates/sign/tests/it/chain.rs index 2b6b5f85a3..1cd5f028ac 100644 --- a/crates/sign/tests/it/chain.rs +++ b/crates/sign/tests/it/chain.rs @@ -348,13 +348,15 @@ fn an_unreadable_extended_key_usage_allows_no_purpose() { fn an_issuer_with_an_unprocessed_critical_extension_does_not_vouch() { let name_constraints = ext("2.5.29.30", &der::seq(&[])); let r = root(); - let constrained = issue("Name Constrained CA", Some(&r), FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints.clone()]); + let constrained = + issue("Name Constrained CA", Some(&r), FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints.clone()]); let l = end_entity("Outside Its Names", &constrained); let pool = [constrained.cert.clone(), r.cert.clone()]; let (chain, why) = build_chain_noted(&l.cert, &pool, None); assert_eq!(names(&chain), ["Outside Its Names"]); assert!(why.is_some_and(|w| w.message.contains("critical extensions")), "the reason is reported"); - let constrained_root = issue("Name Constrained Root", None, FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints]); + let constrained_root = + issue("Name Constrained Root", None, FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints]); let i = ca("Under Constrained Root", &constrained_root, None); let l = end_entity("Leaf", &i); let pool = [i.cert.clone(), constrained_root.cert.clone()]; diff --git a/crates/sign/tests/it/crypto.rs b/crates/sign/tests/it/crypto.rs index 193fd06c62..f184d7161a 100644 --- a/crates/sign/tests/it/crypto.rs +++ b/crates/sign/tests/it/crypto.rs @@ -482,7 +482,8 @@ fn a_responder_that_copies_the_issuers_name_does_not_answer_for_it() { let issuer = &id.certificate; let (this, next) = (mid_june(), Time { year: 2026, month: 7, day: 15, hour: 12, minute: 0, second: 0 }); let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap(); - let impostor = Certificate::self_signed(&issuer.subject, &key, Time { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, 5, &[9]).unwrap(); + let impostor = + Certificate::self_signed(&issuer.subject, &key, Time { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, 5, &[9]).unwrap(); assert_eq!(impostor.subject.raw, issuer.subject.raw); let cert_id = der::seq(&[ &DigestAlg::Sha1.algorithm(), diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index f7dc33f685..61517b53d3 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -435,10 +435,7 @@ impl TimestampAuthority for TestTsa { #[test] fn signing_with_a_timestamp_embeds_a_verified_rfc3161_token() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap(); assert!(signed.starts_with(&fixture()), "still an incremental update"); let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); @@ -474,10 +471,7 @@ fn a_malformed_timestamp_response_fails_signing_without_a_file() { #[test] fn a_document_timestamp_covers_the_file_and_validates() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); assert!(stamped.starts_with(&fixture()), "an incremental update"); let trust_tsa = TrustStore { certs: vec![tsa.id.certificate.clone()] }; @@ -511,10 +505,7 @@ fn a_document_timestamp_covers_the_file_and_validates() { #[test] fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let mut source = open(&fixture()); source .update_dict(source.root().unwrap(), |d| { @@ -590,10 +581,7 @@ fn embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid() { fn sign_then_ltv_then_timestamp_makes_a_b_lta_file() { use pdfcraft_sign::dss::{self, Evidence}; let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); let ltv = dss::embed(&open(&signed), &Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }).unwrap(); let lta = pdfcraft_sign::timestamp_document(&open(<v), &tsa, "D:20261006120000Z").unwrap(); @@ -1563,10 +1551,7 @@ fn an_untrusted_timestamp_does_not_set_the_validation_time() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); // The TSA claims 2027 (inside the signer's validity) but is not trusted; the signer's own // clock says 2040, after the certificate expired. - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 } }; let late = SignOptions { date: "D:20400101120000Z".into(), ..opts() }; let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &late, &tsa).unwrap(); let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); @@ -1785,10 +1770,7 @@ fn fixture_timestamped_in_a_field(tsa: &TestTsa) -> Vec { #[test] fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let stamped = fixture_timestamped_in_a_field(&tsa); let trust = TrustStore { certs: vec![tsa.id.certificate.clone()] }; let all = signatures(&open(&stamped), &stamped, &trust); @@ -1833,10 +1815,7 @@ fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() { #[test] fn a_standalone_timestamp_typed_sig_by_an_older_writer_is_still_a_timestamp() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); let s = signatures(&open(&stamped), &stamped, &TrustStore::default()).into_iter().find(|s| s.doc_timestamp).unwrap(); assert_eq!(s.status, Status::Unknown, "{:?}", s.details); @@ -2067,10 +2046,7 @@ fn signing_waits_for_a_pending_security_change_to_be_saved() { #[test] fn encrypted_documents_take_a_document_timestamp() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let base = protected(pdfcraft_cos::Algorithm::Aes128, FILL_AND_SIGN_ONLY); let stamped = pdfcraft_sign::timestamp_document(&open_pw(&base, None), &tsa, "D:20261006120000Z").unwrap(); assert!(stamped.starts_with(&base)); From 7cdde6c91c3343a3af23088dfdd69c1a69bd823b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:43:26 +0000 Subject: [PATCH 073/517] chore(assets): record the changed translation catalogues in ATTRIBUTION.toml --- ATTRIBUTION.md | 6 +++--- ATTRIBUTION.toml | 24 ++++++++++++------------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4efb31105f..593ae6e685 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -13,13 +13,13 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | @@ -285,7 +285,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | | `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format | | `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | | `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 7f70df3f8b..3f42a54011 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7358300715a54e72a477c5888dad53f426ddf76da141a6202bec74594eb5ff0a" +sha256 = "b08f8282923d2faaf1f658d2aff8f71fe5f2d3a33e792eeb50fb0e2b396ef24f" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,18 +63,18 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "265083ea7dfa129fcae0b9e401aef8f7c84982011ba91fb2023c52705b81e639" +sha256 = "df17dada3df736e75bd9b03e5ecceec164cce63767e33a1efdb98c56a121c890" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" title = "Spanish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ffcf42859cfe38ae13c6fe4219cbe6225a20b4572c202db9e82ee38bfb433799" +sha256 = "8e8c0cfda0886c584e5286370ec00814206c3af3699c76665e636fa6fdb6310b" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -135,12 +135,12 @@ sha256 = "76f138293fc12ea3584568a81f6f90a1cedfff6256e78b4f60cb21b1b66bbba4" path = "crates/ui-egui/src/i18n/pl.tsv" title = "Polish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7bca90240f873fcec687bea84e9ea5835f0903827501d0e73ec26b98b82e8a52" +sha256 = "0bd75a93f8d18deb832223bd0642cc4f4abde04a322b684a143a9d4b4687a344" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -250,7 +250,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dea5076ce9c19f054812b678241931ad5e7f85dee08acf1c1e12c7ecaaa5d0f5" +sha256 = "bbef7f4e16f83e9e0116278e7a0a175bad79757f96a18dc639516572533f6d5b" [[asset]] path = "crates/ui-egui/src/i18n/hi.tsv" @@ -292,7 +292,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "18d4beda8495d7f0b91f0dc0a52c2248d9ce6fb3f4e3de0d51f42235eaab7aa8" +sha256 = "00a2153c20a191fbe04f1ce61f140fd9628cfe2f8f75dad5bce8a7406e1bd091" [[asset]] @@ -304,7 +304,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7b13e11191a3f2c0e38f1940996ae62da16e3bdb057215b2737f3a9c6a840000" +sha256 = "609efa92054aa76de4bb6062c0fcc0a8b450519f4b253b4c0f395b3cbb34d2ba" [[asset]] path = "assets/icons/accessibility.svg" @@ -3421,12 +3421,12 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" path = "crates/ui-egui/src/i18n/vi.tsv" title = "Vietnamese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1c73f9d51b599923d704bf00c8e56b23696de16141b7a2c20aae6f60f88927a5" +sha256 = "57f0dd007ad16518fbc87d8aeac840ef6d86412bb460aff9fb17b176408fb7a8" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3537,4 +3537,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cde106ae68eea0a2d6229863ea21bbea235e4c3c5e3e728d52689d54041f0593" +sha256 = "5879ee3540ffb630fc83d0f2cb293cc6cbf2e081ea4577161a770794e8cbd22c" From d5047aa4acdf35158c3943839dc5c98f16a8f955 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:07:32 +0000 Subject: [PATCH 074/517] test(cos): build the private temp directory without an unused mut on Windows The only mutable use of the DirBuilder is the Unix mode call, so the binding is mutable only where that call exists. --- crates/cos/tests/it/encryption.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index 8d03d22790..2c6aad244d 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -237,9 +237,14 @@ fn private_temp_dir(prefix: &str) -> std::path::PathBuf { let mut name = std::collections::hash_map::RandomState::new().build_hasher(); name.write_u32(std::process::id()); let dir = std::env::temp_dir().join(format!("{prefix}-{:016x}", name.finish())); - let mut builder = std::fs::DirBuilder::new(); #[cfg(unix)] - std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + let builder = { + let mut builder = std::fs::DirBuilder::new(); + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder + }; + #[cfg(not(unix))] + let builder = std::fs::DirBuilder::new(); builder.create(&dir).unwrap(); dir } From 19f40e6554415bd897c5315e4863762625d3a557 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 21:21:00 +0500 Subject: [PATCH 075/517] The two file tests ask which file, not how the OS says it is missing Review of #3849: both tests asserted `stderr.contains("No such file")`, which is the Unix wording. On Windows the same error reads "The system cannot find the file specified. (os error 2)", so `test (windows-latest)` failed on them. Neither test is about the wording. What they are about: - `a_flag_that_takes_no_value_does_not_swallow_the_file`: the error is about `missing.pdf`, and is not "check-one: missing file" -- the file reached the command instead of being eaten as `--edit`'s operand. - `a_file_whose_name_starts_with_dashes_is_still_reachable`: the error names the file the command could not open (`missing.pdf`, or `./--dash.pdf` for the `info` case), and is not a usage error about a value or an unknown option. Both now assert exactly that, plus the absence of our own usage phrases. Every string they match is one this CLI prints, so the OS has no say in it. --- apps/pdfcraft-cli/tests/options.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/pdfcraft-cli/tests/options.rs b/apps/pdfcraft-cli/tests/options.rs index 14b3e617c6..7955157e28 100644 --- a/apps/pdfcraft-cli/tests/options.rs +++ b/apps/pdfcraft-cli/tests/options.rs @@ -65,16 +65,19 @@ fn a_file_option_refuses_a_value_that_is_another_option() { /// is as far as these lines get here. #[test] fn a_file_whose_name_starts_with_dashes_is_still_reachable() { - for args in [ - vec!["extract", "missing.pdf", "--pages", "1", "--out", "./--dash.pdf"], - vec!["edit", "missing.pdf", "--out", "./--dash.pdf"], - vec!["info", "./--dash.pdf"], + // The error is about the file the command could not open, so the test asks which file it + // names -- not how the OS words "not found", which differs on Windows (#3849 review). + for (names, args) in [ + ("missing.pdf", vec!["extract", "missing.pdf", "--pages", "1", "--out", "./--dash.pdf"]), + ("missing.pdf", vec!["edit", "missing.pdf", "--out", "./--dash.pdf"]), + ("./--dash.pdf", vec!["info", "./--dash.pdf"]), ] { let out = Command::new(BIN).args(&args).output().expect("run cli"); let stderr = String::from_utf8_lossy(&out.stderr); assert!(!out.status.success(), "{args:?}: no such file either way"); - assert!(stderr.contains("No such file"), "{args:?}: {stderr}"); - assert!(!stderr.contains("needs a value"), "{args:?}: {stderr}"); + assert!(stderr.contains(names), "{args:?}: the error is about {names}: {stderr}"); + // Not a usage error: the `./--dash.pdf` reached the command as a file name. + assert!(!stderr.contains("needs a value") && !stderr.contains("unknown option"), "{args:?}: {stderr}"); } } @@ -86,8 +89,10 @@ fn a_flag_that_takes_no_value_does_not_swallow_the_file() { let out = Command::new(BIN).args(&args).output().expect("run cli"); let stderr = String::from_utf8_lossy(&out.stderr); assert!(!out.status.success(), "{args:?}"); - assert!(stderr.contains("missing.pdf") && stderr.contains("No such file"), "{args:?}: {stderr}"); + // Which file the error is about, not how the OS says it is missing (#3849 review). + assert!(stderr.contains("missing.pdf"), "{args:?}: the error is about the file: {stderr}"); assert!(!stderr.contains("missing file"), "the file was found, and then not found: {stderr}"); + assert!(!stderr.contains("needs a value"), "{args:?}: {stderr}"); } } From 7d6af8a508a257df7b55e1a3e30fb3a1e56fab51 Mon Sep 17 00:00:00 2001 From: Hitesh Date: Thu, 22 Oct 2026 21:04:22 +0530 Subject: [PATCH 076/517] fix: Delete removes the selected paragraph or objects in Edit text A paragraph selected by its frame (or objects taken by an area selection) answered the arrow keys and Escape but not Delete, so the only way to remove text was one typed character at a time in the inline editor. Delete or Backspace now removes the whole selection as one undo step: paragraphs and images last-first in their inventories, added content after them, under the existing "Delete content" history label. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/object_ui.rs | 73 ++++++++++++++++++++++++++++++ crates/ui-egui/tests/it/editing.rs | 29 ++++++++++++ parity/acrobat-features.toml | 4 +- 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64b22ff18e..aea4b6f844 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3023,6 +3023,7 @@ impl PdfCraftApp { && let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_modification()) { object_ui::nudge_keys(ctx, &mut self.views[i], doc); + object_ui::delete_keys(ctx, &mut self.views[i]); } // A selected field moves with the arrows too (Prepare a form, #2608), before the view // turns the page with them. diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index 029dc5bc91..7f81f76a24 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -384,6 +384,45 @@ pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, doc: &Document } } +/// Delete (or Backspace) removes the selected objects, as in Acrobat: a paragraph selected by its +/// frame, or everything an area selection took, goes at once instead of one typed character at a +/// time. +pub(crate) fn delete_keys(ctx: &egui::Context, view: &mut DocView) { + if ctx.egui_wants_keyboard_input() || view.pending_edit.is_some() || view.objects.gesture.is_some() { + return; + } + let Some(page) = view.objects.page.filter(|_| !view.objects.selected.is_empty()) else { return }; + use egui::{Key, Modifiers}; + if !ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace)) { + return; + } + view.pending_edit = delete_edit(page, view.objects.selected.iter().map(|o| o.target).collect()); + view.objects.clear(); +} + +/// The edit that deletes `targets` (indexes into one inventory of `page`) as one undo step. +/// Each deletion renumbers only what follows it in its own inventory, so paragraphs and images +/// go from the last to the first, and added content, which the source inventories leave out, +/// after them. +fn delete_edit(page: usize, mut targets: Vec) -> Option { + targets.sort_by_key(|t| (t.kind == ObjectKind::Added, std::cmp::Reverse(t.index))); + targets.dedup(); + let mut edits: Vec = targets + .into_iter() + .map(|t| match t.kind { + ObjectKind::Text => Edit::EditTextBlock { page, block: t.index, text: String::new(), style: pdfcraft_engine::BlockStyle::default() }, + ObjectKind::Image => Edit::EditPageImage { page, index: t.index, change: pdfcraft_engine::ImageEdit::Delete }, + ObjectKind::Added => Edit::DeleteContent { page, index: t.index }, + }) + .collect(); + if edits.len() > 1 { + // "Delete content" is an edit label already, so the history reads the same in every language. + Some(Edit::Batch { label: "Delete content".into(), edits }) + } else { + edits.pop() + } +} + pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, selection: &ObjectSelection) { if selection.page != Some(page) { return; @@ -418,6 +457,8 @@ pub(crate) fn move_error(error: &pdfcraft_engine::EditError) -> String { #[cfg(test)] mod tests { + use super::*; + #[test] fn selection_labels_and_refusals_are_translated_in_every_catalog() { for language in crate::i18n::LANGUAGES.iter().filter(|l| !l.source.is_empty()) { @@ -439,4 +480,36 @@ mod tests { assert_ne!(crate::i18n::trn(lang, 2, "{n} object selected", "{n} objects selected"), "2 objects selected", "{}", language.code); } } + + fn t(kind: ObjectKind, index: usize) -> ObjectTarget { + ObjectTarget { kind, index } + } + + /// Deleting renumbers what follows in the same inventory, so the batch takes each inventory + /// from its last item to its first, and added content (outside the source inventories) last. + #[test] + fn several_objects_delete_last_first_in_one_step() { + let targets = vec![t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0), t(ObjectKind::Text, 3), t(ObjectKind::Added, 2)]; + let Some(Edit::Batch { label, edits }) = delete_edit(0, targets) else { panic!("several objects make one batch") }; + assert_eq!(label, "Delete content"); + let order: Vec<(char, usize)> = edits + .iter() + .map(|e| match e { + Edit::EditTextBlock { block, text, .. } => { + assert!(text.is_empty()); + ('t', *block) + } + Edit::EditPageImage { index, change: pdfcraft_engine::ImageEdit::Delete, .. } => ('i', *index), + Edit::DeleteContent { index, .. } => ('a', *index), + other => panic!("unexpected {}", other.label().wording), + }) + .collect(); + assert_eq!(order, [('t', 3), ('t', 1), ('i', 0), ('a', 2), ('a', 0)]); + } + + #[test] + fn one_object_deletes_with_its_own_edit_and_none_with_nothing() { + assert!(matches!(delete_edit(2, vec![t(ObjectKind::Image, 4)]), Some(Edit::EditPageImage { page: 2, index: 4, .. }))); + assert!(delete_edit(0, Vec::new()).is_none()); + } } diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 71ba9ccdac..38c4ddbdbb 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -5980,3 +5980,32 @@ fn mode_tabs_keep_the_hand_over_a_paragraph_scrolled_under_them() { click_at(&mut h, convert); assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the paragraph's editor"); } + +/// A paragraph selected by its frame goes with one Delete (or Backspace), as in Acrobat, instead +/// of being emptied a typed character at a time, and undo brings it back. +#[test] +fn a_paragraph_selected_by_its_frame_deletes_with_one_key() { + for key in [Key::Delete, Key::Backspace] { + let mut h = harness(2, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let id = h.state().views[0].id; + let blocks = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().text_blocks(0); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 200.0; + let screen = |x: f64, y: f64| egui::pos2(r.left() + x as f32 * k, r.top() + (300.0 - y as f32) * k); + let b = blocks(&h)[0].clone(); + click_at(&mut h, screen((b.rect[0] + b.rect[2]) / 2.0, (b.rect[1] + b.rect[3]) / 2.0)); + assert!(h.state().views[0].line_editor.is_some(), "a click on the text opens it for typing"); + click_at(&mut h, screen((b.rect[0] + b.rect[2]) / 2.0, b.rect[3]) - egui::vec2(0.0, 4.0)); + assert!(h.state().views[0].line_editor.is_none(), "the frame selects the box"); + press(&mut h, Modifiers::NONE, key); + assert!(blocks(&h).is_empty(), "{key:?} deletes the whole paragraph: {:?}", blocks(&h)); + assert!(h.state().views[0].line_editor.is_none(), "no empty editor is left behind"); + assert_eq!(h.state().session.get(id).unwrap().text_blocks(1)[0].text, "Page 2", "the next page keeps its text"); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 2, "Delete removed the paragraph, not a page"); + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(2); + assert_eq!(blocks(&h).iter().map(|b| b.text.as_str()).collect::>(), ["Page 1"], "undo restores it"); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 362c2aa776..db4cc28196 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2202,8 +2202,8 @@ milestone = "M11" status = "partial" commands = ["edit.edit_text"] tools = ["object_list", "object_move"] -evidence = ["crates/ui-egui/tests/it/object_selection.rs::marquee_and_mixed_group_drag_move_once_and_undo_together", "crates/automation/tests/it/automation.rs::mixed_objects_move_through_tools_with_generation_and_one_undo", "crates/edit/src/tests.rs::group_moves_preserve_glyph_bytes_and_unselected_text_state", "crates/engine/src/tests.rs::mixed_object_moves_are_one_undo_step_and_survive_save_reopen"] -notes = "Same-page mixed selection (#844): Shift/Ctrl/Command-click toggles, empty-space marquee (additive with modifiers), drag previews and one atomic undo step for existing paragraphs, Image/Form XObjects and added text/images. Source glyph bytes, fonts and kerning retained; unselected text and shared Form resources stay put. Generation-scoped references with optional automation guard; count and geometry bounded; unsupported vertical/clipping text refused without partial changes. Still missing: arbitrary paths, inline images, cross-page movement, group resizing, arrange/align and a general object editor. See docs/editing-multiple-objects.md." +evidence = ["crates/ui-egui/tests/it/object_selection.rs::marquee_and_mixed_group_drag_move_once_and_undo_together", "crates/automation/tests/it/automation.rs::mixed_objects_move_through_tools_with_generation_and_one_undo", "crates/edit/src/tests.rs::group_moves_preserve_glyph_bytes_and_unselected_text_state", "crates/engine/src/tests.rs::mixed_object_moves_are_one_undo_step_and_survive_save_reopen", "crates/ui-egui/tests/it/editing.rs::a_paragraph_selected_by_its_frame_deletes_with_one_key"] +notes = "Same-page mixed selection (#844): Shift/Ctrl/Command-click toggles, empty-space marquee (additive with modifiers), drag previews and one atomic undo step for existing paragraphs, Image/Form XObjects and added text/images. Source glyph bytes, fonts and kerning retained; unselected text and shared Form resources stay put. Delete or Backspace removes the selection (a paragraph selected by its frame, or a group) as one undo step. Generation-scoped references with optional automation guard; count and geometry bounded; unsupported vertical/clipping text refused without partial changes. Still missing: arbitrary paths, inline images, cross-page movement, group resizing, arrange/align and a general object editor. See docs/editing-multiple-objects.md." [[feature]] id = "edit.object-properties" From 9a76f90f070e0b3d11f61bf42e4e30a7da26b43f Mon Sep 17 00:00:00 2001 From: Oliver Hagmann <268387159+ohagmann@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:38:28 +0200 Subject: [PATCH 077/517] M5: the arrow keys move the selected comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a comment selected, the arrow keys scrolled the view (↑ ↓) or turned the page (← →), so a text box, rectangle or line could only be placed with the mouse. Now an arrow key moves the selected comment a screen pixel, and ⌘/Ctrl or Shift and an arrow ten, as Edit text nudges objects; up is up on screen whatever the zoom, the page's rotation and the view's. Each press is one undo step (Edit::MoveAnnotation). The keys are taken before the view's navigation, which has them back once nothing is selected. Text markup stays with its text, and a locked comment where it is. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 ++ crates/ui-egui/src/comments.rs | 49 +++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/tests/it/comments.rs | 44 ++++++++++++++++++++++++++ docs/ui-parity.md | 2 +- parity/acrobat-features.toml | 12 +++++++ 6 files changed, 110 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index bc057209c2..9164899c0a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -225,6 +225,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged. - **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged. +- **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged. + - **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. diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index d80f988b73..541e1c8ad7 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1823,6 +1823,55 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool } } +/// The arrow keys move the selected comment a screen pixel; with ⌘/Ctrl or Shift, ten. Up is up +/// on screen whatever the zoom, the page's rotation and the view's; the keys are taken before +/// the view scrolls or turns the page with them. Each press is one undo step. Text markup stays +/// with its text, and a locked comment where it is. +pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) { + if ctx.egui_wants_keyboard_input() || view.pending_edit.is_some() { + return; + } + let cv = &view.comments; + if cv.gesture.is_some() || cv.composer.is_some() { + return; + } + let Some((page, index)) = cv.selected else { return }; + let movable = + info.annotations.iter().any(|a| a.page == page && a.index == index && a.in_reply_to.is_none() && !is_markup(&a.subtype) && !a.locked); + let (Some(xf), Some(p)) = (view.page_xform(page), info.pages.get(page)) else { return }; + if !movable { + return; + } + use egui::{Key, Modifiers}; + let mut step = egui::Vec2::ZERO; + ctx.input_mut(|i| { + for (key, direction) in + [(Key::ArrowLeft, vec2(-1.0, 0.0)), (Key::ArrowRight, vec2(1.0, 0.0)), (Key::ArrowUp, vec2(0.0, -1.0)), (Key::ArrowDown, vec2(0.0, 1.0))] + { + // The modified patterns first: a plain one also matches a Shift press. + if i.consume_key(Modifiers::COMMAND, key) || i.consume_key(Modifiers::SHIFT, key) { + step += direction * 10.0; + } else if i.consume_key(Modifiers::NONE, key) { + step += direction; + } + } + }); + if step == egui::Vec2::ZERO { + return; + } + // The step on screen, in user space: from the page's centre to `step` pixels away. + let user = |s: Pos2| { + let (vx, vy) = xf.screen_to_view(s); + let u = p.view_to_user(vx, vy); + [f64::from(u[0]), f64::from(u[1])] + }; + let (a, b) = (user(xf.rect.center()), user(xf.rect.center() + step)); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + if dx.is_finite() && dy.is_finite() && (dx != 0.0 || dy != 0.0) { + view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy }); + } +} + /// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's /// menus as in the Attachments panel: the comment is where the reader finds its file. Reading /// the file changes nothing, so a read-only document offers them too. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64b22ff18e..d618e4ba27 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3023,6 +3023,8 @@ impl PdfCraftApp { && let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_modification()) { object_ui::nudge_keys(ctx, &mut self.views[i], doc); + } else if let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_annotation()) { + comments::nudge_keys(ctx, &mut self.views[i], &doc.info); } // A selected field moves with the arrows too (Prepare a form, #2608), before the view // turns the page with them. diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 6194c75163..5e62278437 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -860,6 +860,50 @@ fn line_ends_are_dragged_to_change_length_and_angle() { assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "{p:?}"); } +/// The arrow keys move the selected comment a screen pixel, ⌘/Ctrl or Shift and an arrow ten, +/// instead of scrolling or turning the page; each press is one undo step. Without a selection +/// the arrows scroll and turn pages as before. +#[test] +fn arrow_keys_nudge_the_selected_comment() { + use egui::{Key, Modifiers}; + let mut h = harness(|app| app.set_option("quick", "square").unwrap()); + drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0)); + h.state_mut().set_option("quick", "select").unwrap(); + click_pt(&mut h, (90.0, 70.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0))); + // One screen pixel in user space, at this zoom. + let pixel = 300.0 / h.state().views[0].page_screen_rect(0).unwrap().width(); + let before = comments(&h)[0].rect; + h.key_press(Key::ArrowRight); + h.run_steps(3); + let r = comments(&h)[0].rect; + assert!((r[0] - before[0] - pixel).abs() < 0.01 && (r[1] - before[1]).abs() < 0.01, "one pixel right: {r:?} from {before:?}"); + assert_eq!(h.state().views[0].current, 0, "the page didn't turn"); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comment")); + // ⌘/Ctrl and Shift: ten pixels (down on screen is down in user space here). + h.key_press_modifiers(Modifiers::COMMAND, Key::ArrowDown); + h.run_steps(3); + let r2 = comments(&h)[0].rect; + assert!((r[1] - r2[1] - 10.0 * pixel).abs() < 0.01 && (r2[0] - r[0]).abs() < 0.01, "ten pixels down: {r2:?} from {r:?}"); + h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft); + h.run_steps(3); + let r3 = comments(&h)[0].rect; + assert!((r2[0] - r3[0] - 10.0 * pixel).abs() < 0.01, "ten pixels left: {r3:?} from {r2:?}"); + h.state_mut().undo(); + h.run_steps(2); + assert!((comments(&h)[0].rect[0] - r2[0]).abs() < 0.01, "each press is one undo step"); + // Nothing selected: → turns the page again. + h.key_press(Key::Escape); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.selected, None); + let rect = comments(&h)[0].rect; + h.key_press(Key::ArrowRight); + h.run_steps(3); + assert_eq!(comments(&h)[0].rect, rect, "the comment stays"); + assert_eq!(h.state().views[0].current, 1, "and the view goes to the next page"); +} + #[test] fn a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool() { // #2338: the new shape showed its handles, but only the Select tool answered them. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c12461793a..703f1e9d8b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -52,7 +52,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Rulers, grids, snap to grid, guides | yes | no | planned | | Cursor coordinates, line weights | yes | no | planned | | Measuring snap to paths, endpoints, midpoints, intersections | yes | yes | shipped | -| Nudge selected objects with arrow keys (Shift for larger steps) | yes | yes in Edit text: a point, Shift ten points, for selected objects, images and an open paragraph's box (#1520); and in Prepare a form, for the selected field (#2608) | shipped | +| Nudge selected objects with arrow keys (Shift for larger steps) | yes | yes in Edit text: a point, Shift ten points, for selected objects, images and an open paragraph's box (#1520); and in Prepare a form, for the selected field (#2608); a selected comment: a screen pixel, ⌘/Ctrl or Shift ten | shipped | | Single-key tool accelerators (H, V, Z, …) | yes, opt-in | no | planned | | Context menus on pages, thumbnails, comments, fields | yes | thumbnails, comments, fields | partial | | Comment pop-ups as movable windows, connector lines | yes | inline in the panel | planned | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 362c2aa776..d55fe67f62 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3971,6 +3971,18 @@ tools = ["comment_delete"] evidence = ["crates/ui-egui/tests/it/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"] notes = "deletes the pop-up and the reply thread" +[[feature]] +id = "comment.keyboard-nudge" +title = "Move the selected comment with the arrow keys" +area = "E" +tier = "P2" +milestone = "M5" +status = "shipped" +commands = [] +tools = ["comment_edit"] +evidence = ["crates/ui-egui/tests/it/comments.rs::arrow_keys_nudge_the_selected_comment"] +notes = "With a comment selected (Select tool, or a Fill & Sign mark picked up), an arrow key moves it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, up being up on screen whatever the zoom and rotation; each press is one undo step, and the arrows scroll and turn pages again once nothing is selected. Text markup stays with its text and a locked comment where it is. Headless, comment_edit's `move` takes the offset in points." + [[feature]] id = "comment.reshape" title = "Change a line's ends and a polygon's corners after drawing" From 667bc697b224d8b96a3557d7d5d5aa6e721f7304 Mon Sep 17 00:00:00 2001 From: Janiz-zzz <267742026+Janiz-zzz@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:47:08 +0300 Subject: [PATCH 078/517] i18n(fi): translate the 64 labels left in English after #2210 Replaces the English placeholders added during the #2210 integration (calculation order, text formatting, print, thumbnails, layers, comment reshape and signature checks, plus the two signature wordings from 3e180944) with Finnish, and updates the fi.tsv sha256 and the entry count in docs/localization.md. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 2 +- ATTRIBUTION.toml | 4 +- crates/ui-egui/src/i18n/fi.tsv | 130 ++++++++++++++++----------------- docs/localization.md | 2 +- 4 files changed, 69 insertions(+), 69 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 593ae6e685..f0de55d670 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -34,7 +34,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 3f42a54011..1fcac5f6d4 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -299,12 +299,12 @@ sha256 = "00a2153c20a191fbe04f1ce61f140fd9628cfe2f8f75dad5bce8a7406e1bd091" path = "crates/ui-egui/src/i18n/fi.tsv" title = "Finnish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "609efa92054aa76de4bb6062c0fcc0a8b450519f4b253b4c0f395b3cbb34d2ba" +sha256 = "fd9c4035f567f5b495e61ba8422f76b9a50c914811fc80b4ecaa1005e100bd15" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index efcf3e3c6e..66808a1cdc 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2496,7 +2496,7 @@ animation effects Off Ei käytössä The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Allekirjoittajan varmennetta ei ole myönnetty asiakirjojen allekirjoittamiseen (sen laajennettu avaimen käyttö ei sisällä asiakirjojen allekirjoitusta). The signer's certificate has critical extensions PdfCraft does not recognize ({}). Allekirjoittajan varmenteessa on kriittisiä laajennuksia, joita PdfCraft ei tunnista ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Kohteen {} varmenne ei ole varmenteita myöntävä CA-varmenne, joten sitä ei käytetä kohteen {} vahvistamiseen. - The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Kohteen {} varmenteessa on kriittisiä laajennuksia, joita PdfCraft ei tunnista, joten sitä ei käytetä kohteen {} vahvistamiseen. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Kohteen {} varmenne ei salli näin montaa alempaa CA-varmennetta (polun pituus), joten sitä ei käytetä kohteen {} vahvistamiseen. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Kohteen {} varmenne ei ollut voimassa allekirjoitushetkellä, joten sitä ei käytetä kohteen {} vahvistamiseen. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Allekirjoittajan henkilöllisyys on tuntematon, koska sitä ei ole lisätty luotettujen varmenteiden luetteloon eikä mikään sen ylemmistä varmenteista ole luotettu varmenne. @@ -2516,7 +2516,7 @@ animation effects Off Ei käytössä The timestamp token could not be read ({}). Aikaleimatunnistetta ei voitu lukea ({}). The document has been altered or corrupted since the timestamp was applied. Asiakirjaa on muutettu tai se on vioittunut aikaleiman lisäämisen jälkeen. The timestamp authority's certificate was not valid at the time of timestamping. Aikaleiman myöntäjän varmenne ei ollut voimassa aikaleimaushetkellä. - The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. + The timestamp authority's certificate is not issued for time stamping. Aikaleiman myöntäjän varmennetta ei ole myönnetty aikaleimaukseen. This document has not been modified since this timestamp was applied. Asiakirjaa ei ole muokattu tämän aikaleiman lisäämisen jälkeen. The document has been modified since this timestamp was applied, but the changes are permitted ({}). Asiakirjaa on muokattu tämän aikaleiman lisäämisen jälkeen, mutta muutokset ovat sallittuja ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). Asiakirjaa on muutettu tämän aikaleiman lisäämisen jälkeen tavoilla, joita se ei salli ({}). @@ -2696,69 +2696,69 @@ theme Appearance Teema # Labels added upstream 2026-10-16…18. the name is not a plain file name nimi ei ole pelkkä tiedostonimi Zoom — type a percentage and press Enter Zoomaus — kirjoita prosentti ja paina Enter -# Untranslated: English text until a translator fills these in. - Calculation order Calculation order - Fix order Fix order - Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. - Earlier in calculation order Earlier in calculation order - Later in calculation order Later in calculation order - Too early Too early - Reads {name}, which calculates later, so it shows an old value. Reads {name}, which calculates later, so it shows an old value. - Loop Loop - Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Reads {names}, which read it back. No order can fix a loop: change one of the formulas. - Not in order Not in order - Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. - Back to Prepare a form Back to Prepare a form - No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. - {names} and {last} {names} and {last} - {field} reads no other field {field} reads no other field - {field} reads {fields} {field} reads {fields} - No field reads it No field reads it - The order saved in the PDF needs tidying The order saved in the PDF needs tidying - Moves only what must move Moves only what must move - Every field calculates after the fields it reads. Every field calculates after the fields it reads. - Every field outside a loop calculates after the fields it reads. Every field outside a loop calculates after the fields it reads. - {field} reads its own value {field} reads its own value - {fields} read each other {fields} read each other - No order can fix a loop. Change one of the formulas. No order can fix a loop. Change one of the formulas. - Edit {field}'s formula Edit {field}'s formula - Order · top calculates first Order · top calculates first - Script Script - Links on page Links on page - Show on the page which fields each field reads Show on the page which fields each field reads - Drag a field to move it, or select it and press {up} or {down}. Drag a field to move it, or select it and press {up} or {down}. - Set calculation order Set calculation order -@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} calculation now uses the new name.|{n} calculations now use the new name. -@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. -@plural {fields} reads it|{fields} read it {fields} reads it|{fields} read it -@plural {n} field shows an old value|{n} fields show old values {n} field shows an old value|{n} fields show old values -@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. -@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} more field calculates too early.|{n} more fields calculate too early. -@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. - Bulleted list Bulleted list - Numbered list Numbered list - Alignment Alignment - Superscript Superscript - Subscript Subscript - Paragraph spacing Paragraph spacing - Less Less - Other options Other options - Always use my default zoom and page layout Always use my default zoom and page layout - Larger thumbnails Larger thumbnails - Layer options Layer options - Printer default Printer default - Reading the printer's settings… Reading the printer's settings… - Reset to Initial Visibility Reset to Initial Visibility - Shrink the page content to make room for the header and footer Shrink the page content to make room for the header and footer - Smaller thumbnails Smaller thumbnails - The document's security settings don't allow printing The document's security settings don't allow printing - Thumbnail size Thumbnail size - Reshape comment Reshape comment - The signature's signed attributes have no content type, which CMS requires; it was read anyway. The signature's signed attributes have no content type, which CMS requires; it was read anyway. - This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on. This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on. - The signature's signed attributes have no message digest, so the signature does not cover the document. The signature's signed attributes have no message digest, so the signature does not cover the document. - The signature's signed attributes name a content type other than data, so it is not a signature of the document. The signature's signed attributes name a content type other than data, so it is not a signature of the document. - Remove {standard} identification Remove {standard} identification +# Calculation order, text formatting, print and signature labels (AI-assisted, not separately native-reviewed) + Calculation order Laskentajärjestys + Fix order Korjaa järjestys + Move only what must move, so every field calculates after the fields it reads. Siirrä vain se, mikä on pakko, jotta jokainen kenttä lasketaan lukemiensa kenttien jälkeen. + Earlier in calculation order Aiemmaksi laskentajärjestyksessä + Later in calculation order Myöhemmäksi laskentajärjestyksessä + Too early Liian aikaisin + Reads {name}, which calculates later, so it shows an old value. Lukee kenttää {name}, joka lasketaan myöhemmin, joten se näyttää vanhan arvon. + Loop Silmukka + Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lukee kenttiä {names}, jotka lukevat sitä takaisin. Mikään järjestys ei korjaa silmukkaa: muuta jotakin kaavoista. + Not in order Ei järjestyksessä + Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Puuttuu PDF:ään tallennetusta laskentajärjestyksestä: muut PDF-katseluohjelmat eivät ehkä laske sitä. + Back to Prepare a form Takaisin kohtaan Valmistele lomake + No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Mikään tämän lomakkeen kenttä ei vielä laske arvoaan. Anna kentälle laskenta sen ominaisuuksien Laskenta-välilehdellä, niin se näkyy tässä. + {names} and {last} {names} ja {last} + {field} reads no other field {field} ei lue muita kenttiä + {field} reads {fields} {field} lukee: {fields} + No field reads it Mikään kenttä ei lue sitä + The order saved in the PDF needs tidying PDF:ään tallennettu järjestys kaipaa siistimistä + Moves only what must move Siirtää vain sen, mikä on pakko + Every field calculates after the fields it reads. Jokainen kenttä lasketaan lukemiensa kenttien jälkeen. + Every field outside a loop calculates after the fields it reads. Jokainen silmukan ulkopuolinen kenttä lasketaan lukemiensa kenttien jälkeen. + {field} reads its own value {field} lukee omaa arvoaan + {fields} read each other {fields} lukevat toisiaan + No order can fix a loop. Change one of the formulas. Mikään järjestys ei korjaa silmukkaa. Muuta jotakin kaavoista. + Edit {field}'s formula Muokkaa kentän {field} kaavaa + Order · top calculates first Järjestys · ylin lasketaan ensin + Script Komentosarja + Links on page Yhteydet sivulla + Show on the page which fields each field reads Näytä sivulla, mitä kenttiä kukin kenttä lukee + Drag a field to move it, or select it and press {up} or {down}. Siirrä kenttää vetämällä tai valitse se ja paina {up} tai {down}. + Set calculation order Aseta laskentajärjestys +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} laskenta käyttää nyt uutta nimeä.|{n} laskentaa käyttää nyt uutta nimeä. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. Laskentajärjestyksessä on {n} kohta, joka ei laske mitään. Korjaa järjestys poistaa sen.|Laskentajärjestyksessä on {n} kohtaa, jotka eivät laske mitään. Korjaa järjestys poistaa ne. +@plural {fields} reads it|{fields} read it {fields} lukee sitä|{fields} lukevat sitä +@plural {n} field shows an old value|{n} fields show old values {n} kenttä näyttää vanhan arvon|{n} kenttää näyttää vanhoja arvoja +@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} lasketaan ennen lukemaansa kenttää {fields}.|{field} lasketaan ennen lukemiaan kenttiä {fields}. +@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} muu kenttä lasketaan liian aikaisin.|{n} muuta kenttää lasketaan liian aikaisin. +@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} puuttuu PDF:ään tallennetusta järjestyksestä, joten muut PDF-katseluohjelmat eivät ehkä laske sitä.|{fields} puuttuvat PDF:ään tallennetusta järjestyksestä, joten muut PDF-katseluohjelmat eivät ehkä laske niitä. + Bulleted list Luettelomerkit + Numbered list Numeroitu luettelo + Alignment Tasaus + Superscript Yläindeksi + Subscript Alaindeksi + Paragraph spacing Kappaleväli + Less Vähemmän + Other options Muut asetukset + Always use my default zoom and page layout Käytä aina oletuszoomaustani ja -sivuasetteluani + Larger thumbnails Suuremmat pikkukuvat + Layer options Tasoasetukset + Printer default Tulostimen oletus + Reading the printer's settings… Luetaan tulostimen asetuksia… + Reset to Initial Visibility Palauta alkuperäinen näkyvyys + Shrink the page content to make room for the header and footer Pienennä sivun sisältöä, jotta ylä- ja alatunnisteelle jää tilaa + Smaller thumbnails Pienemmät pikkukuvat + The document's security settings don't allow printing Asiakirjan suojausasetukset eivät salli tulostamista + Thumbnail size Pikkukuvan koko + Reshape comment Muuta kommentin muotoa + The signature's signed attributes have no content type, which CMS requires; it was read anyway. Allekirjoituksen allekirjoitetuista määritteistä puuttuu sisältötyyppi, jota CMS edellyttää; se luettiin silti. + This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on. Tämä allekirjoitus käyttää alle 2048-bittistä RSA-avainta, jota ei enää pidetä riittävän vahvana allekirjoituksiin. Se tarkistuu yhä, mutta siihen ei pidä luottaa. + The signature's signed attributes have no message digest, so the signature does not cover the document. Allekirjoituksen allekirjoitetuista määritteistä puuttuu viestitiiviste, joten allekirjoitus ei kata asiakirjaa. + The signature's signed attributes name a content type other than data, so it is not a signature of the document. Allekirjoituksen allekirjoitetut määritteet nimeävät muun sisältötyypin kuin data, joten se ei ole asiakirjan allekirjoitus. + Remove {standard} identification Poista {standard}-tunniste # Prepare a form ▸ Text field table (AI-assisted, not separately native-reviewed) Add text field table Lisää tekstikenttätaulukko diff --git a/docs/localization.md b/docs/localization.md index ccacccd09b..b5e78ece1b 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -24,7 +24,7 @@ The Lithuanian catalog has 2,585 entries and three integer plural forms. Calenda The Vietnamese catalog has 2,674 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). Coverage tests require every current command, UI literal, bundled key and credit label. A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. -The Finnish catalog has 2,674 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. +The Finnish catalog has 2,762 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. The Catalan catalog has 2,592 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/it/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation. From cc1086a86221dd18ec713b9800449747236383f4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:28 +0000 Subject: [PATCH 079/517] fix(audit): keep the pending path across Q The cover walk cleared the current path when it restored the graphics state, but the current path is not part of the graphics state. A rectangle built before Q and filled after it paints a box over the text, and the audit missed it and reported the page clean. --- crates/audit/src/cover.rs | 3 ++- crates/audit/src/tests.rs | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 3df666c190..20ed811d40 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -144,10 +144,11 @@ impl<'a> Walker<'a> { match op.op.as_slice() { b"q" => stack.push(gs.clone()), b"Q" => { + // The current path is not graphics state (ISO 32000-2 §8.4.1): `re Q f` still + // fills the rectangle, whose corners are already in user space. if let Some(g) = stack.pop() { *gs = g; } - path = Path::Empty; } b"cm" => { let before = gs.ctm; diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 4ca3b0bf5b..ded3a865ca 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -592,3 +592,13 @@ fn reading_blocks_respect_stream_comments_and_split_operands() { assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1)); } } + +/// The current path is not graphics state: a rectangle built before `Q` and filled after it +/// paints the box, so it is a cover. +#[test] +fn rectangle_filled_after_restore_is_a_cover() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\nq 100 688 220 32 re Q f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); +} From 4c7ba85bebefcc4be2bd457faf5d30d07ef52e38 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:34 +0000 Subject: [PATCH 080/517] fix(audit): resolve an indirect /Subtype before skipping an object The annotation scan and the form XObject walk read /Subtype only as a direct name. A /Subtype stored as a reference to the name read as none, so a /Redact annotation or a covering form named that way was skipped as an unrelated object and the page reported clean. Both now follow the reference, and a reference that cannot be loaded fails the audit. --- crates/audit/src/cover.rs | 2 +- crates/audit/src/lib.rs | 12 +++++++++++- crates/audit/src/tests.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 20ed811d40..d308c75a23 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -259,7 +259,7 @@ impl<'a> Walker<'a> { let resolved = self.doc.resolve(entry); let Object::Stream(stream) = &*resolved else { return Ok(()) }; let dict = &stream.dict; - if dict.name(b"Subtype") != Some(b"Form") { + if crate::subtype(self.doc, dict)?.as_deref() != Some(b"Form") { return Ok(()); } let matrix = dict diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 365889d61a..b79353e85d 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -229,7 +229,7 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro continue; } let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]; - match dict.name(b"Subtype") { + match subtype(doc, &dict)?.as_deref() { Some(b"Redact") => out.push(AnnotationCover::Redact(rect)), Some(b"Square") => { let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); @@ -249,6 +249,16 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro Ok(out) } +/// The `/Subtype` name of `dict`, following an indirect reference. A reference that cannot be +/// loaded fails the audit: the subtype decides whether the object is checked at all. +fn subtype(doc: &Document, dict: &Dict) -> Result>, AuditError> { + let name = match dict.get(b"Subtype") { + Some(pdfcraft_cos::Object::Ref(r)) => doc.try_get(r.num)?.as_name().map(<[u8]>::to_vec), + other => other.and_then(pdfcraft_cos::Object::as_name).map(<[u8]>::to_vec), + }; + Ok(name) +} + fn annotation_color(doc: &Document, dict: &Dict, key: &[u8]) -> Option<[f64; 3]> { let values: Vec = dict.get(key).map(|o| doc.resolve(o))?.as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect::>()?; if !values.iter().all(|v| v.is_finite()) { diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index ded3a865ca..9be067ae0d 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -602,3 +602,32 @@ fn rectangle_filled_after_restore_is_a_cover() { assert_eq!(findings.len(), 1, "{findings:?}"); assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); } + +/// A `/Subtype` may be an indirect reference to the name, like any other value: a `/Redact` +/// annotation and a covering form XObject named that way are still checked. +#[test] +fn indirect_subtype_is_resolved() { + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>"); + b.add_stream(b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n"); + b.add(FONT); + b.add("<< /Type /Annot /Subtype 7 0 R /Rect [95 685 305 725] >>"); + b.add("/Redact"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::UnappliedMark); + + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 6 0 R >> /XObject << /X 5 0 R >> >> >>"); + b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n/X Do\n"); + b.add_stream_with(b"0 g\n100 688 220 32 re f\n", "/Type /XObject /Subtype 7 0 R /BBox [0 0 612 792]"); + b.add(FONT); + b.add("/Form"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::CoveredText); +} From 3e306c22ef1dd255911e5c5ae4c2b9753ecf8299 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:43 +0000 Subject: [PATCH 081/517] fix(audit): compare a cover with the start of a merged text line A text line joins compatible runs on one baseline, and the audit took the line's last painting operator as its paint position. Text drawn after a cover on the same baseline joined the covered words and moved the line past the cover, so the covered words went unreported. The audit now compares against the line's first painting operator: any run painted before the cover is checked, and runs painted after it in the same low-contrast color are unreadable on it too. --- crates/audit/src/lib.rs | 10 ++++++---- crates/audit/src/tests.rs | 13 +++++++++++++ crates/edit/src/text.rs | 9 +++++---- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index b79353e85d..6dc329d9ae 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -143,11 +143,13 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> } /// A filled rectangle is a faux-redaction cover for a text line iff it was painted strictly -/// after the line, overlaps it substantially, and is dark enough against the line's color to -/// make the text unreadable. Light covers (table shading, highlights) over dark text fail the -/// contrast check and are not flagged. +/// after the start of the line, overlaps it substantially, and is dark enough against the line's +/// color to make the text unreadable. Light covers (table shading, highlights) over dark text fail +/// the contrast check and are not flagged. A line can join runs painted before and after the +/// cover; the later ones are drawn on it in the same low-contrast color, so they are unreadable +/// too. fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool { - rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST + rect.pos > line.first_paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST } /// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough: diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 9be067ae0d..9d5a1726d2 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -631,3 +631,16 @@ fn indirect_subtype_is_resolved() { assert_eq!(findings.len(), 1, "{findings:?}"); assert_eq!(findings[0].kind, FindingKind::CoveredText); } + +/// Text drawn after a cover on the same baseline joins the covered text in one line. The cover +/// still hides the words painted before it. +#[test] +fn later_text_on_the_line_does_not_clear_an_earlier_cover() { + let content = b"0 g\nBT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n"; + let doc = open(one_page(content, "")); + let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text"); + assert_eq!(lines.len(), 1, "the runs join one line: {:?}", lines.iter().map(|l| &l.text).collect::>()); + let findings = audit_redactions(&doc).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 93a5eec99e..1861b7816e 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -528,12 +528,13 @@ impl TextLine { self.origin.baseline } - /// Paint position of the line's last text-showing operator: `(content-stream index, - /// operator index in the page's joined stream)`. Lines paint in order of this tuple. + /// Paint position of the line's first operator: `(content-stream index, operator index in + /// the page's joined stream)`. A line joins runs painted at different times, so anything + /// painted after this position may lie on top of part of it. /// (Lines from `text_lines` never come from form XObjects, so `stream` is always a /// page content-stream index and `ops` are indices into the joined operator list.) - pub fn paint_pos(&self) -> (usize, usize) { - (self.stream, self.ops.iter().max().copied().unwrap_or(0)) + pub fn first_paint_pos(&self) -> (usize, usize) { + (self.stream, self.ops.iter().min().copied().unwrap_or(0)) } fn rotation(&self) -> f64 { From 65ee18d470acf96dc25acef5f2f024312b03b0e3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:49 +0000 Subject: [PATCH 082/517] fix(audit): bound cover-line comparisons and covered text Every cover was compared with every text line of its page, and each finding kept its own copy of the text it covered. Both grow with the square of the content: a few megabytes of covers over one long line meant hours of comparisons and gigabytes of report. The audit now counts comparisons per page and covered-text bytes across the document and fails with an error past either bound, never with a partial report. --- crates/audit/src/lib.rs | 86 ++++++++++++++++++++++++++++----------- crates/audit/src/tests.rs | 31 ++++++++++++++ 2 files changed, 94 insertions(+), 23 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 6dc329d9ae..d827159570 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -79,6 +79,10 @@ pub enum AuditError { TooDeep, #[error("page has a malformed /Annots entry")] BadAnnots, + #[error("page has more covers and text lines than the audit compares")] + TooManyComparisons, + #[error("covered text exceeds the audit's report budget")] + TooMuchOutput, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), #[error(transparent)] @@ -95,41 +99,77 @@ const WORD_FRACTION: f64 = 0.5; const COVER_CONTRAST: f64 = 3.0; /// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any -/// content that cannot be decoded is returned as an error, never as an empty finding list. +/// content that cannot be decoded, or that needs more work or report than the audit's bounds, is +/// returned as an error, never as an empty finding list. pub fn audit_redactions(doc: &Document) -> Result, AuditError> { + audit(doc, Limits::DEFAULT) +} + +/// Work and output bounds for one audit. +#[derive(Clone, Copy)] +struct Limits { + /// Cover-against-line comparisons per page. + comparisons: usize, + /// Bytes of covered text across all findings. + covered_text: usize, +} + +impl Limits { + /// Every cover is compared with every line of its page, and each finding repeats the text + /// it covers, so both grow with the square of the content: a few megabytes of covers over + /// one long line would otherwise take hours and gigabytes. + const DEFAULT: Limits = Limits { comparisons: 50_000_000, covered_text: 64 << 20 }; +} + +/// The work and report the audit has spent against its [`Limits`]. +struct Budget { + limits: Limits, + comparisons: usize, + covered_text: usize, +} + +impl Budget { + /// The text of the `lines` that `hides`, joined with single spaces. + fn hidden_text(&mut self, lines: &[TextLine], hides: impl Fn(&TextLine) -> bool) -> Result { + self.comparisons = self.comparisons.saturating_add(lines.len()); + if self.comparisons > self.limits.comparisons { + return Err(AuditError::TooManyComparisons); + } + let mut covered = String::new(); + for line in lines.iter().filter(|l| hides(l)) { + let text = line.text.trim(); + self.covered_text = self.covered_text.saturating_add(text.len() + 1); + if self.covered_text > self.limits.covered_text { + return Err(AuditError::TooMuchOutput); + } + if !covered.is_empty() { + covered.push(' '); + } + covered.push_str(text); + } + Ok(covered) + } +} + +fn audit(doc: &Document, limits: Limits) -> Result, AuditError> { + let mut budget = Budget { limits, comparisons: 0, covered_text: 0 }; let mut out = Vec::new(); for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() { let lines = pdfcraft_edit::text_lines(doc, n).map_err(|e| AuditError::Text(n, e.to_string()))?; + budget.comparisons = 0; // 1. Opaque filled rectangles over extractable text. for r in cover::filled_rects(doc, &page.dict)? { - let mut covered = String::new(); - for line in &lines { - if is_cover(&r, line) { - if !covered.is_empty() { - covered.push(' '); - } - covered.push_str(line.text.trim()); - } - } + let covered = budget.hidden_text(&lines, |line| is_cover(&r, line))?; if !covered.trim().is_empty() { out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered }); } } // 2. Annotation covers over extractable text. for annot in annots(doc, &page.dict)? { - let mut covered = String::new(); - for line in &lines { - let hidden = match &annot { - AnnotationCover::Redact(rect) => covers(rect, line), - AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST, - }; - if hidden { - if !covered.is_empty() { - covered.push(' '); - } - covered.push_str(line.text.trim()); - } - } + let covered = budget.hidden_text(&lines, |line| match &annot { + AnnotationCover::Redact(rect) => covers(rect, line), + AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST, + })?; if !covered.trim().is_empty() { let (kind, rect) = match annot { AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect), diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 9d5a1726d2..ed809bc633 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -644,3 +644,34 @@ fn later_text_on_the_line_does_not_clear_an_earlier_cover() { assert_eq!(findings.len(), 1, "{findings:?}"); assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); } + +/// Many covers over the same long line repeat its text in every finding: a small file must not +/// expand into an unbounded report. Past the budget the audit fails rather than truncate. +#[test] +fn covered_text_across_findings_is_bounded() { + let text = "X".repeat(60_000); + let mut content = format!("BT /F1 1 Tf 0 700 Td ({text}) Tj ET\n0 g\n").into_bytes(); + for i in 0..1_200 { + content.extend_from_slice(format!("-{i} 690 50000 20 re f\n").as_bytes()); + } + let result = audit_redactions(&open(one_page(&content, ""))); + assert!(matches!(result, Err(crate::AuditError::TooMuchOutput)), "{:?}", result.map(|f| f.len())); +} + +/// Covers compared against lines are bounded per page, so content that is quadratic in its +/// covers and lines fails the audit instead of running for hours. +#[test] +fn cover_and_line_comparisons_are_bounded() { + let mut content = Vec::new(); + for i in 0..20 { + content.extend_from_slice(format!("BT /F1 12 Tf 72 {} Td (Line {i}) Tj ET\n", 700 - 14 * i).as_bytes()); + } + for _ in 0..20 { + content.extend_from_slice(b"1 g 0 0 10 10 re f\n"); + } + let doc = open(one_page(&content, "")); + let limits = crate::Limits { comparisons: 399, covered_text: usize::MAX }; + assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); + let limits = crate::Limits { comparisons: 400, covered_text: usize::MAX }; + assert!(crate::audit(&doc, limits).expect("within the budget").is_empty()); +} From 3fce90e94a3ec5dd70f977814f75f80f9dd74bb6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:47:03 +0000 Subject: [PATCH 083/517] fix(audit): withhold covered text when copying is not permitted The audit returned the text under each cover whatever the document's permissions, and the CLI and the automation tool printed it. A user whose password withholds copying could read through the audit the text that text extraction refuses them. The audit now reports every finding for such a document with empty covered text; the owner password lifts the restriction as it does elsewhere. --- crates/audit/README.md | 7 ++++++- crates/audit/src/lib.rs | 13 ++++++++++--- crates/audit/src/tests.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/audit/README.md b/crates/audit/README.md index 37771a5350..ce2c47915f 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -33,7 +33,12 @@ Limits, by design and documented rather than hidden: less than 40% of its words (a sliver of a long value) is not flagged. It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper -than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings". +than 32 or in a cycle, a walk past the operator bound, more than 50 million cover-against-line +comparisons on a page, or more than 64 MiB of covered text in the report is an error, never "no +findings". + +A document whose permissions withhold copying (Table 22, bit 5) still gets every finding, with +an empty `covered_text`: the audit hands out no text that copying would refuse. Surfaces: the `doc_audit_redactions` automation tool and `pdfcraft-cli audit`. Both report each rectangle in points from the top-left of the page as displayed (y down, after `/Rotate`), like diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index d827159570..4f1877ef67 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -65,7 +65,7 @@ pub struct AuditFinding { /// `rect[0] <= rect[2]` and `rect[1] <= rect[3]`. pub rect: [f64; 4], /// The extractable text the cover hides, lines joined with single spaces and trimmed. - /// Possibly empty when nothing decodable remains under the cover. + /// Empty when the document's permissions withhold copying (see [`audit_redactions`]). pub covered_text: String, } @@ -101,6 +101,10 @@ const COVER_CONTRAST: f64 = 3.0; /// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any /// content that cannot be decoded, or that needs more work or report than the audit's bounds, is /// returned as an error, never as an empty finding list. +/// +/// When the document's permissions withhold copying (ISO 32000-2 Table 22, bit 5), findings are +/// still reported but their `covered_text` is empty: the audit hands out no text that copying +/// would refuse. pub fn audit_redactions(doc: &Document) -> Result, AuditError> { audit(doc, Limits::DEFAULT) } @@ -152,6 +156,7 @@ impl Budget { } fn audit(doc: &Document, limits: Limits) -> Result, AuditError> { + let copying = doc.permissions().is_none_or(|p| p.copy()); let mut budget = Budget { limits, comparisons: 0, covered_text: 0 }; let mut out = Vec::new(); for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() { @@ -161,7 +166,8 @@ fn audit(doc: &Document, limits: Limits) -> Result, AuditError for r in cover::filled_rects(doc, &page.dict)? { let covered = budget.hidden_text(&lines, |line| is_cover(&r, line))?; if !covered.trim().is_empty() { - out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered }); + let covered_text = if copying { covered } else { String::new() }; + out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text }); } } // 2. Annotation covers over extractable text. @@ -175,7 +181,8 @@ fn audit(doc: &Document, limits: Limits) -> Result, AuditError AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect), AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect), }; - out.push(AuditFinding { page: n, kind, rect, covered_text: covered }); + let covered_text = if copying { covered } else { String::new() }; + out.push(AuditFinding { page: n, kind, rect, covered_text }); } } } diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index ed809bc633..de501d01a7 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -675,3 +675,29 @@ fn cover_and_line_comparisons_are_bounded() { let limits = crate::Limits { comparisons: 400, covered_text: usize::MAX }; assert!(crate::audit(&doc, limits).expect("within the budget").is_empty()); } + +/// Covered text is document text: a document whose permissions withhold copying reports where +/// its covers are but not what they hide, unless it was opened with the owner password. +#[test] +fn copy_restricted_documents_withhold_covered_text() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n"; + let mut doc = open(one_page(content, "")); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 0b1_0000, + encrypt_metadata: true, + seed: [7; 32], + }) + .expect("encrypt"); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).expect("save")); + let user = Document::open_with_password(bytes.clone(), None).expect("open"); + let findings = audit_redactions(&user).expect("audit must run"); + assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}"); + assert_eq!(findings[0].covered_text, ""); + let owner = Document::open_with_password(bytes, Some("owner")).expect("open"); + let findings = audit_redactions(&owner).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].covered_text, "SECRET DATA 123"); +} From 693eae1d56063ee2cf2b185cf9cc0486c6b3bae5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:49:00 +0000 Subject: [PATCH 084/517] docs(parity): cite the audit's new tests and record its bounds and copy restriction --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 362c2aa776..700b74b8da 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5841,8 +5841,8 @@ milestone = "M8" status = "partial" commands = [] tools = ["doc_audit_redactions"] -evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "crates/automation/tests/it/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/it/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/it/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"] -notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." +evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "crates/automation/tests/it/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/it/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/it/audit.rs::audit_rectangles_match_the_tool_in_top_left_points", "crates/audit/src/tests.rs::rectangle_filled_after_restore_is_a_cover", "crates/audit/src/tests.rs::later_text_on_the_line_does_not_clear_an_earlier_cover", "crates/audit/src/tests.rs::indirect_subtype_is_resolved", "crates/audit/src/tests.rs::copy_restricted_documents_withhold_covered_text", "crates/audit/src/tests.rs::cover_and_line_comparisons_are_bounded", "crates/audit/src/tests.rs::covered_text_across_findings_is_bounded"] +notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the start of the text line it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content, or more than 50 million cover-against-line comparisons on a page or 64 MiB of covered text in the report, is an error, never a clean bill. A document that withholds copying gets every finding with the covered text left out. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." [[feature]] id = "protect.remove-hidden-on-close" From 733e38bfa71840d1e96e806d81a2544871ec7d85 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:06:47 +0000 Subject: [PATCH 085/517] fix(cos): a decode budget shared by every stream one walk reads MAX_DECODED bounds one decode, so a page whose /Contents repeats a reference to one compressed stream, or a form drawn many times, gets a fresh 1 GiB allowance per occurrence. DecodeBudget is one cumulative decoded-byte allowance plus a form-entry count and a seen-set of stream references, shared by a whole page walk. A decode that runs past the remaining allowance spends all of it. --- crates/cos/src/budget.rs | 135 +++++++++++++++++++++++++++++++++++++++ crates/cos/src/lib.rs | 2 + crates/cos/src/object.rs | 14 +++- 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 crates/cos/src/budget.rs diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs new file mode 100644 index 0000000000..b89d06001a --- /dev/null +++ b/crates/cos/src/budget.rs @@ -0,0 +1,135 @@ +//! A shared allowance for operations that decode many streams. + +use std::collections::HashSet; + +use pdfcraft_filters::FilterError; + +use crate::{CosError, Document, ObjRef, Object, Stream}; + +/// Decoded bytes one page's content walk may produce in all: its `/Contents` streams and every +/// form XObject it enters, each occurrence counted again. +pub const MAX_PAGE_DECODED: usize = 256 << 20; +/// Form XObjects one page's content walk may enter, each entry counted again. +pub const MAX_PAGE_FORMS: usize = 50_000; + +/// What one operation may still decode and enter across all the streams it reads. +/// +/// [`MAX_DECODED`](crate::MAX_DECODED) bounds one decode. It does not bound a page whose +/// `/Contents` names the same compressed stream thousands of times, or a form drawn thousands of +/// times from other forms: each occurrence would get a fresh allowance. A `DecodeBudget` is +/// shared by every decode of one operation, so the total decoded bytes and form entries stay +/// under fixed limits however the file repeats its references. A decode that runs past the +/// remaining allowance spends all of it, so a bomb referenced many times is inflated once. +#[derive(Debug)] +pub struct DecodeBudget { + bytes: usize, + forms: usize, + seen: HashSet, +} + +impl DecodeBudget { + /// An allowance of `bytes` decoded bytes and `forms` form entries. + pub fn new(bytes: usize, forms: usize) -> Self { + Self { bytes, forms, seen: HashSet::new() } + } + + /// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms. + pub fn page() -> Self { + Self::new(MAX_PAGE_DECODED, MAX_PAGE_FORMS) + } + + /// Decoded bytes still allowed. + pub fn remaining(&self) -> usize { + self.bytes + } + + /// Whether a decode was refused for lack of allowance or a form entry for lack of visits. + pub fn exhausted(&self) -> bool { + self.bytes == 0 || self.forms == 0 + } + + /// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without + /// decoding, once the allowance is spent. + pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { + if self.bytes == 0 && !stream.raw.is_empty() { + return Err(CosError::Filter(FilterError::LimitExceeded(0).to_string())); + } + match stream.decoded_within_typed(self.bytes) { + Ok(data) => { + self.bytes = self.bytes.saturating_sub(data.len()); + Ok(data) + } + Err(e) => { + if matches!(e, FilterError::LimitExceeded(_)) { + self.bytes = 0; + } + Err(CosError::Filter(e.to_string())) + } + } + } + + /// Counts one form entry. `false` once [`DecodeBudget::new`]'s `forms` entries are spent: + /// the caller leaves that form out. + pub fn enter_form(&mut self) -> bool { + match self.forms.checked_sub(1) { + Some(left) => { + self.forms = left; + true + } + None => false, + } + } + + /// `true` the first time `r` is passed to this budget, for callers that need each + /// referenced stream only once. + pub fn first_visit(&mut self, r: ObjRef) -> bool { + self.seen.insert(r) + } + + /// Each stream `contents` names, decoded against the allowance, in order. `contents` is a + /// page's `/Contents`: one stream, or an array whose non-stream entries are left out. + /// A stream that fails to decode, or arrives after the allowance is spent, is an `Err` in its + /// place, so the caller chooses between skipping it and giving up. + pub fn decode_contents(&mut self, doc: &Document, contents: &Object) -> Vec, CosError>> { + match &*doc.resolve(contents) { + Object::Stream(s) => vec![self.decode(s)], + Object::Array(list) => list + .iter() + .filter_map(|entry| match &*doc.resolve(entry) { + Object::Stream(s) => Some(self.decode(s)), + _ => None, + }) + .collect(), + _ => Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Dict; + + #[test] + fn repeated_references_share_one_allowance() { + let mut doc = Document::new_empty(); + let bomb = doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]))); + let contents = Object::Array(vec![Object::Ref(bomb); 64]); + let mut budget = DecodeBudget::new(10 << 20, 1); + let decoded = budget.decode_contents(&doc, &contents); + assert_eq!(decoded.len(), 64); + assert_eq!(decoded.iter().filter(|d| d.is_ok()).count(), 10); + let total: usize = decoded.iter().flatten().map(Vec::len).sum(); + assert_eq!(total, 10 << 20); + assert!(budget.exhausted()); + // A decode past the allowance spends it, so a bomb is inflated at most once more. + let mut budget = DecodeBudget::new((1 << 20) - 1, 1); + assert!(budget.decode_contents(&doc, &contents).iter().all(Result::is_err)); + assert_eq!(budget.remaining(), 0); + + assert!(budget.enter_form()); + assert!(!budget.enter_form()); + assert!(budget.first_visit(bomb)); + assert!(!budget.first_visit(bomb)); + } +} diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 152daed2de..f3be66b9a1 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -13,6 +13,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod budget; mod bytes; mod document; mod object; @@ -21,6 +22,7 @@ mod parser; mod security; mod writer; +pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS}; pub use bytes::Bytes; pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 438b8d5f35..fe33388be3 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -214,11 +214,21 @@ impl Stream { /// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than /// [`MAX_DECODED`]: decoding past `max` bytes is an error. pub fn decoded_within(&self, max: usize) -> Result, CosError> { + self.decoded_within_typed(max).map_err(|e| CosError::Filter(e.to_string())) + } + + /// [`Stream::decoded_within`] with the filter error kept typed, so a caller can tell a + /// spent limit from corrupt data. + pub(crate) fn decoded_within_typed(&self, max: usize) -> Result, pdfcraft_filters::FilterError> { + let max = max.min(MAX_DECODED); let chain = self.filters(); if chain.is_empty() { - return self.raw_within(max); + if self.raw.len() > max { + return Err(pdfcraft_filters::FilterError::LimitExceeded(max)); + } + return Ok(self.raw.to_vec()); } - pdfcraft_filters::decode_tolerant(&chain, &self.raw, max.min(MAX_DECODED)).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string())) + pdfcraft_filters::decode_tolerant(&chain, &self.raw, max).map(|(v, _)| v) } /// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error. From 13d9c9bf8c83d7f87484ab4665f9998abb691392 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:48 +0000 Subject: [PATCH 086/517] fix(a11y): page content and forms share one decode budget per page The checker decoded every /Contents entry and every form a page drew with a fresh 1 GiB allowance each, so a page repeating one compressed stream reference produced gigabytes. The two copies of the page-content helper are one now, and it and the form walk charge a per-page DecodeBudget. --- crates/a11y/src/alt.rs | 30 ++++++++++++++---------------- crates/a11y/src/content.rs | 33 +++++++++------------------------ crates/a11y/src/tests.rs | 10 ++++++++++ 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index e4755ab385..57f9390b16 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::Matrix; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString, Stream}; /// A Figure element (after the role map). #[derive(Clone, Debug, PartialEq)] @@ -157,22 +157,19 @@ fn bdc_mcid(doc: &Document, props: &Dict, op: &pdfcraft_content::Op) -> Option Vec { + page_content_within(doc, page, &mut DecodeBudget::page()) +} + +/// The page's content streams, decoded against `budget` and joined with a space. Streams that +/// don't decode, or arrive after the budget is spent, are left out. +pub(crate) fn page_content_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Vec { let Some(c) = page.get(b"Contents") else { return Vec::new() }; - match &*doc.resolve(c) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => a - .iter() - .flat_map(|x| match &*doc.resolve(x) { - Object::Stream(s) => { - let mut v = s.decoded().unwrap_or_default(); - v.push(b' '); - v - } - _ => Vec::new(), - }) - .collect(), - _ => Vec::new(), + let mut out = Vec::new(); + for data in budget.decode_contents(doc, c).into_iter().flatten() { + out.extend_from_slice(&data); + out.push(b' '); } + out } fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { @@ -322,9 +319,10 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) }; let props = page_properties(doc, &page.dict); let mut streams: Vec)>> = Vec::with_capacity(entries.len()); + let mut budget = DecodeBudget::page(); for entry in &entries { match &*doc.resolve(entry) { - Object::Stream(s) => streams.push(Some((s.clone(), s.decoded().unwrap_or_default()))), + Object::Stream(s) => streams.push(Some((s.clone(), budget.decode(s).unwrap_or_default()))), _ => streams.push(None), } } diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index 8f7221b2c0..66f83867e3 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object}; use pdfcraft_model::Page; use crate::{Finding, Rule}; @@ -30,38 +30,22 @@ impl Scan { for &p in list { let Some(page) = pages.get(p) else { continue }; let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let data = page_content(doc, &page.dict); - let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new() }; + let mut budget = DecodeBudget::page(); + let data = crate::alt::page_content_within(doc, &page.dict, &mut budget); + let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new(), budget }; w.content(&data, &res, false, 0); } s } } -fn page_content(doc: &Document, page: &Dict) -> Vec { - let Some(c) = page.get(b"Contents") else { return Vec::new() }; - match &*doc.resolve(c) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => a - .iter() - .flat_map(|x| match &*doc.resolve(x) { - Object::Stream(s) => { - let mut v = s.decoded().unwrap_or_default(); - v.push(b' '); - v - } - _ => Vec::new(), - }) - .collect(), - _ => Vec::new(), - } -} - struct Walk<'a> { doc: &'a Document, scan: &'a mut Scan, page: usize, forms: HashSet, + /// Decoded bytes and form entries left for this page. + budget: DecodeBudget, } impl Walk<'_> { @@ -116,14 +100,15 @@ impl Walk<'_> { self.scan.has_images = true; untagged += usize::from(!inside); } - Some(b"Form") if depth < MAX_FORM_DEPTH && self.forms.insert(r) => { + Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) && self.budget.enter_form() => { + self.forms.insert(r); let inner = x .dict .get(b"Resources") .map(|o| self.doc.resolve(o)) .and_then(|o| o.as_dict().cloned()) .unwrap_or_else(|| res.clone()); - let data = x.decoded().unwrap_or_default(); + let data = self.budget.decode(x).unwrap_or_default(); self.content(&data, &inner, inside, depth + 1); self.forms.remove(&r); } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 15ca643e83..84a836cbf9 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1191,3 +1191,13 @@ fn horizontally_scaled_wrapped_heading_keeps_a_space_between_lines() { let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); assert_eq!(titles, ["I Overview"]); } + +#[test] +fn repeated_content_references_share_one_decode_budget() { + use pdfcraft_cos::{Dict, MAX_PAGE_DECODED, Object, Stream}; + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect(); + let data = crate::alt::page_content(&doc, &page); + assert!(data.len() <= MAX_PAGE_DECODED + 300, "{} bytes", data.len()); +} From 0b6adb70dab892dbead62e2c33bf5d3ef1557077 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:09:44 +0000 Subject: [PATCH 087/517] fix(optimize): page content and forms share one decode budget per page Image measurement and fragment merging decoded every /Contents entry and every form occurrence with a fresh 1 GiB allowance, so repeated references to one compressed stream multiplied memory. Both charge a per-page DecodeBudget. A page whose content does not decode within it is left unmerged, and its images are not resampled, since a use may be missed; fragment merging no longer rewrites a page from content it could not fully decode. --- crates/optimize/src/fragments.rs | 6 ++- crates/optimize/src/images.rs | 67 ++++++++++++++++++++++---------- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index b5967eee09..a73ac9830f 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -18,7 +18,7 @@ //! drops them. use pdfcraft_content::{Matrix, Op, parse, serialize_ops}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; use crate::images::{components, content_bytes, decodable, page_resources, pixels, resample}; use crate::{OptimizeError, Report, Stage}; @@ -213,7 +213,9 @@ fn add_xobject(doc: &mut Document, page: ObjRef, name: &[u8], image: ObjRef) -> /// Merge the fragment runs of one page; returns how many fragments were merged. fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result { let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) }; - let parsed = parse(&content_bytes(doc, &contents)); + // A page whose content isn't all known is left alone: rewriting it would drop the rest. + let Some(data) = content_bytes(doc, &contents, &mut DecodeBudget::page()) else { return Ok(0) }; + let parsed = parse(&data); if parsed.skipped > 0 { return Ok(0); } diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 6182b08922..531c56ece2 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::{Matrix, Pieces}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; @@ -23,21 +23,11 @@ pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { None } -pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec { - match &*doc.resolve(contents) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => { - let pieces: Vec<_> = a - .iter() - .filter_map(|c| match &*doc.resolve(c) { - Object::Stream(s) => Some(s.decoded().unwrap_or_default()), - _ => None, - }) - .collect(); - Pieces::join(&pieces).data().to_vec() - } - _ => Vec::new(), - } +/// The page content `contents` names, decoded against `budget` and joined. `None` when a stream +/// doesn't decode or the budget runs out: the content is not all known. +pub(crate) fn content_bytes(doc: &Document, contents: &Object, budget: &mut DecodeBudget) -> Option> { + let pieces = budget.decode_contents(doc, contents).into_iter().collect::, _>>().ok()?; + Some(Pieces::join(&pieces).data().to_vec()) } /// Forms nested deeper than this aren't followed. @@ -57,6 +47,8 @@ struct Walk<'a> { /// Forms walked at least once. seen: HashSet, rewalks: usize, + /// Decoded bytes left for the page being walked. + budget: DecodeBudget, /// False once a use may have been missed (depth or work limit). complete: bool, out: HashMap, @@ -135,23 +127,42 @@ impl Walk<'_> { } self.rewalks += 1; } + let Ok(data) = self.budget.decode(s) else { + self.complete = false; + return; + }; self.path.push(r); - self.content(&s.decoded().unwrap_or_default(), own.as_ref().unwrap_or(resources), inner); + self.content(&data, own.as_ref().unwrap_or(resources), inner); self.path.pop(); } } /// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses. /// -/// If forms nest too deeply or are drawn too many times to measure every use, every image is -/// given `INFINITY` (never resampled), since its largest use may be among those missed. +/// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within +/// its budget, every image is given `INFINITY` (never resampled), since its largest use may be +/// among those missed. pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap { - let mut walk = Walk { doc, path: Vec::new(), walked: HashSet::new(), seen: HashSet::new(), rewalks: 0, complete: true, out: HashMap::new() }; + let mut walk = Walk { + doc, + path: Vec::new(), + walked: HashSet::new(), + seen: HashSet::new(), + rewalks: 0, + budget: DecodeBudget::page(), + complete: true, + out: HashMap::new(), + }; for p in pages { let Some(d) = doc.get(*p).as_dict().cloned() else { continue }; let Some(contents) = d.get(b"Contents") else { continue }; let res = page_resources(doc, *p).unwrap_or_default(); - walk.content(&content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + walk.budget = DecodeBudget::page(); + let Some(data) = content_bytes(doc, contents, &mut walk.budget) else { + walk.complete = false; + continue; + }; + walk.content(&data, &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); } if !walk.complete { walk.out.values_mut().for_each(|v| *v = f64::INFINITY); @@ -433,3 +444,17 @@ mod dimension_tests { assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4])); } } + +#[cfg(test)] +mod tests { + use pdfcraft_cos::{DecodeBudget, Dict, Document, MAX_PAGE_DECODED, Object, Stream}; + + #[test] + fn repeated_content_references_share_one_decode_budget() { + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let contents = Object::Array(vec![Object::Ref(stream); 300]); + let data = super::content_bytes(&doc, &contents, &mut DecodeBudget::page()); + assert!(data.is_none_or(|d| d.len() <= MAX_PAGE_DECODED + 300)); + } +} From 8c8f56a2e29d77370721864198732685f8e624b8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:11:06 +0000 Subject: [PATCH 088/517] fix(print): page content and label forms share one decode budget per page Printing and the label text-direction check decoded every /Contents entry with a fresh 1 GiB allowance, and the direction check re-entered forms at every Do with only a depth limit, so a few hundred bytes of nested repeated draws meant hundreds of thousands of form walks. Both charge a per-page DecodeBudget: printing refuses a page whose content runs past it, and the direction check stops reading there. --- crates/print/src/lib.rs | 51 +++++++++++++++++++++++---------------- crates/print/src/tests.rs | 39 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index e935cb938e..05b0770ba7 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -18,7 +18,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use pdfcraft_content::Matrix; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; pub mod range; pub mod spool; @@ -331,11 +331,14 @@ pub fn text_reads_up(doc: &Document, index: usize) -> bool { let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut score = 0i64; - text_direction(doc, &decoded_contents(doc, &page.dict), &res, to_display, 0, &mut score); + let mut budget = DecodeBudget::page(); + // Content past the budget is left out: the answer is a guess from the text read so far. + let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default(); + text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget); score > 0 } -fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64) { +fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); for op in pdfcraft_content::parse(data).ops { match op.op.as_slice() { @@ -358,8 +361,11 @@ fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: } let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); - if let Ok(body) = form.decoded() { - text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score); + if !budget.enter_form() { + return; + } + if let Ok(body) = budget.decode(form) { + text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); } } _ => {} @@ -676,24 +682,22 @@ fn n(v: f64) -> String { if s == "-0" { "0".into() } else { s.into() } } -fn decoded_contents(doc: &Document, page: &Dict) -> Vec { - let list: Vec = match page.get(b"Contents") { - None => Vec::new(), - Some(c) => match &*doc.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }, - }; +/// The page's content streams decoded against `budget` and joined; streams that don't decode +/// are left out. `None` when the budget ran out, so part of the content is missing. +fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { + let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; let mut out = Vec::new(); - for o in list { - if let Object::Stream(s) = &*doc.resolve(&o) - && let Ok(d) = s.decoded() - { - out.extend_from_slice(&d); - out.push(b'\n'); + for data in budget.decode_contents(doc, contents) { + match data { + Ok(data) => { + out.extend_from_slice(&data); + out.push(b'\n'); + } + Err(_) if budget.remaining() == 0 => return None, + Err(_) => {} } } - out + Some(out) } /// Does annotation `d` print under `content`? @@ -760,7 +764,12 @@ fn page_form( let page = pdfcraft_model::pages(doc).swap_remove(index); let crop = page.crop(doc); let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut body = if content == Content::FormFieldsOnly { Vec::new() } else { decoded_contents(doc, &page.dict) }; + let mut body = if content == Content::FormFieldsOnly { + Vec::new() + } else { + decoded_contents(doc, &page.dict, &mut DecodeBudget::page()) + .ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))? + }; // Wrap the page content so a state it leaves behind can't affect the annotations. if !body.is_empty() { let mut wrapped = b"q\n".to_vec(); diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 99634f1aa1..fcf1a62689 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -877,3 +877,42 @@ fn driver_options_take_the_ppds_own_names_in_the_ui_language() { } mod appearances; + +#[test] +fn repeated_content_references_share_one_decode_budget() { + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect(); + let data = decoded_contents(&doc, &page, &mut DecodeBudget::page()); + assert!(data.is_none_or(|d| d.len() <= pdfcraft_cos::MAX_PAGE_DECODED + 300)); +} + +/// 20 draws of a form that draws the next form 20 times, four levels down: 168,420 form +/// entries from a few hundred bytes. +fn form_fanout(doc: &mut Document) -> Dict { + let draws = "/X Do ".repeat(20); + let mut form = |content: &str, inner: Option| { + let mut d: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + if let Some(inner) = inner { + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + let res: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + d.set(b"Resources".to_vec(), Object::Dict(res)); + } + doc.add(Stream::from_raw(d, content.as_bytes().to_vec())) + }; + let mut inner = form("BT 0 1 -1 0 0 0 Tm (a) Tj ET", None); + for _ in 0..3 { + inner = form(&draws, Some(inner)); + } + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect() +} + +#[test] +fn text_direction_stops_at_the_form_budget() { + let mut doc = Document::new_empty(); + let res = form_fanout(&mut doc); + let mut score = 0; + text_direction(&doc, "/X Do ".repeat(20).as_bytes(), &res, Matrix::IDENTITY, 0, &mut score, &mut DecodeBudget::page()); + assert!(score > 0 && score <= pdfcraft_cos::MAX_PAGE_FORMS as i64, "score {score}"); +} From 9c9bbe2ad68d36bab7434593fa4842fea7bf3af1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:12:05 +0000 Subject: [PATCH 089/517] fix(organize): page content is joined within one decode budget Pruning and page_as_form joined every /Contents entry with a fresh 1 GiB allowance each, so a page naming one compressed stream many times became gigabytes. Both share one helper that decodes against a per-page DecodeBudget and fails when it runs out. --- crates/organize/src/import.rs | 17 ++++----------- crates/organize/src/prune.rs | 33 ++++++++++++----------------- crates/organize/src/tests.rs | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 33 deletions(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 80f90fb2a4..024dfa5c36 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -1354,19 +1354,10 @@ pub fn page_as_form(dst: &mut Document, src: &Document, page: usize) -> Result<( let (w, h) = (bbox[2] - bbox[0], bbox[3] - bbox[1]); let rotate = d.get(b"Rotate").and_then(|r| src.resolve(r).as_int()).unwrap_or(0).rem_euclid(360); // Content: the page's streams, decoded and joined. - let mut content = Vec::new(); - if let Some(c) = own.get(b"Contents") { - let list = match &*src.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }; - for o in list { - if let Object::Stream(s) = &*src.resolve(&o) { - content.extend(s.decoded().map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?); - content.push(b'\n'); - } - } - } + let content = match own.get(b"Contents") { + Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?, + None => Vec::new(), + }; let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); let resources = match d.get(b"Resources") { Some(r) => copier.copy_value(dst, r, None)?, diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index b5f5e65ffe..a42c342994 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, ObjRef, Object}; use crate::OrganizeError; @@ -223,23 +223,16 @@ impl Pruner<'_> { } } -/// The page's content, concatenated (`/Contents` may be one stream or an array). `None` when a -/// stream can't be decoded: the page may draw names that can't be seen. -fn content_bytes(doc: &Document, contents: &Object) -> Option> { - match &*doc.resolve(contents) { - Object::Stream(s) => s.decoded().ok(), - Object::Array(a) => { - let mut out = Vec::new(); - for c in a { - if let Object::Stream(s) = &*doc.resolve(c) { - out.extend(s.decoded().ok()?); - out.push(b'\n'); - } - } - Some(out) - } - _ => Some(Vec::new()), +/// The page's content, concatenated (`/Contents` may be one stream or an array), decoded +/// against one page's [`DecodeBudget`]. An error when a stream can't be decoded or the budget +/// runs out: the page may draw names that can't be seen. +pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result, CosError> { + let mut out = Vec::new(); + for data in DecodeBudget::page().decode_contents(doc, contents) { + out.extend(data?); + out.push(b'\n'); } + Ok(out) } /// The resources of a page (as `walk` reports them) with their owner. @@ -321,9 +314,9 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result {} - Some(data) => pruner.content(&data, &res_dict, owner, 0), - None => { + Ok(data) if data.is_empty() => {} + Ok(data) => pruner.content(&data, &res_dict, owner, 0), + Err(_) => { pruner.protect_type3_resources(&res_dict, owner); pruner.distrust(&res_dict, owner); } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 7a80e38e4c..bc833257f2 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -3132,3 +3132,43 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_copying_attributes() { assert!(walk.next().is_none()); assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3"]); } + +/// One page whose /Contents names the same 1 MiB stream 300 times (a few kilobytes on disk). +fn repeated_contents() -> Document { + use pdfcraft_cos::{Dict, Stream}; + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let pages = doc.add(Object::Null); + let page: Dict = [ + (b"Type".to_vec(), Object::Name(b"Page".to_vec())), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())), + (b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300])), + ] + .into_iter() + .collect(); + let page = doc.add(page); + let tree: Dict = [ + (b"Type".to_vec(), Object::Name(b"Pages".to_vec())), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(); + doc.set(pages, Object::Dict(tree)); + let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect(); + let catalog = doc.add(catalog); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_content_references_share_one_decode_budget() { + let src = repeated_contents(); + let mut dst = Document::new_empty(); + if let Ok((form, _)) = page_as_form(&mut dst, &src, 0) { + let Object::Stream(s) = &*dst.get(form) else { panic!("not a stream") }; + let len = s.decoded().unwrap().len(); + assert!(len <= pdfcraft_cos::MAX_PAGE_DECODED + 300, "{len} bytes"); + } +} From 92ab0e932d21e0a118900a09081424aa0b3e3195 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:12:11 +0000 Subject: [PATCH 090/517] fix(cos): a spent decode budget reports its whole allowance The error named the bytes left at the failing decode (often 0), which says nothing about why the operation stopped. --- crates/cos/src/budget.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index b89d06001a..35319f13bb 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -22,6 +22,8 @@ pub const MAX_PAGE_FORMS: usize = 50_000; /// remaining allowance spends all of it, so a bomb referenced many times is inflated once. #[derive(Debug)] pub struct DecodeBudget { + /// The whole allowance, for errors. + limit: usize, bytes: usize, forms: usize, seen: HashSet, @@ -30,7 +32,7 @@ pub struct DecodeBudget { impl DecodeBudget { /// An allowance of `bytes` decoded bytes and `forms` form entries. pub fn new(bytes: usize, forms: usize) -> Self { - Self { bytes, forms, seen: HashSet::new() } + Self { limit: bytes, bytes, forms, seen: HashSet::new() } } /// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms. @@ -52,19 +54,18 @@ impl DecodeBudget { /// decoding, once the allowance is spent. pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { if self.bytes == 0 && !stream.raw.is_empty() { - return Err(CosError::Filter(FilterError::LimitExceeded(0).to_string())); + return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } match stream.decoded_within_typed(self.bytes) { Ok(data) => { self.bytes = self.bytes.saturating_sub(data.len()); Ok(data) } - Err(e) => { - if matches!(e, FilterError::LimitExceeded(_)) { - self.bytes = 0; - } - Err(CosError::Filter(e.to_string())) + Err(FilterError::LimitExceeded(_)) => { + self.bytes = 0; + Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) } + Err(e) => Err(CosError::Filter(e.to_string())), } } From bb8ea65ffdc69bf589a40d1ac5b95b5e13899dad Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:14:53 +0000 Subject: [PATCH 091/517] fix(forms): script streams share one decode budget per reading Each field action and document-script entry decoded its /JS stream with a fresh 1 GiB allowance and kept its own copy, so many fields or name-tree pairs naming one compressed stream retained gigabytes. A reading of the fields, one field's actions, and the document scripts each share a 64 MiB DecodeBudget for their script streams. --- crates/forms/src/actions.rs | 7 +++-- crates/forms/src/lib.rs | 52 +++++++++++++++++++++++------------ crates/forms/src/scripting.rs | 3 +- crates/forms/src/tests.rs | 40 +++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 8128b6a719..cdc89e5632 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -105,11 +105,11 @@ fn texts(doc: &Document, o: &Object) -> Vec { } } -fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef]) -> Option { +fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option { let a = doc.resolve(o); let d = a.as_dict()?; Some(match d.name(b"S")? { - b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o).unwrap_or_default()), + b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o, scripts).unwrap_or_default()), b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()), b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()), b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()), @@ -145,13 +145,14 @@ pub fn field_actions(doc: &Document, name: &str) -> Result wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), Some(k) => aa.get(k).cloned(), }; // An action chain (/Next) counts as its first action here. - if let Some(a) = o.and_then(|o| read(doc, &o, &pages)) { + if let Some(a) = o.and_then(|o| read(doc, &o, &pages, &mut scripts)) { out.push((t, a)); } } diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 85284b8cc5..7bd87a2efc 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -14,7 +14,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString}; mod actions; pub mod af; @@ -264,7 +264,7 @@ fn name_obj(text: &str) -> Object { const MAX_LAYER_STATE: usize = 1024; /// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). -fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option { +fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option { // A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action): // clicking it picks a picture, as `buttonImportIcon()` does. if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { @@ -345,7 +345,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option af::button_script(&script(doc, &action)?), + b"JavaScript" => af::button_script(&script(doc, &action, scripts)?), _ => return None, }) } @@ -371,20 +371,30 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option { (!parts.is_empty()).then(|| parts.join(".")) } -/// The JavaScript of an action dictionary (`/JS` string or stream). -fn script(doc: &Document, action: &Object) -> Option { +/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode +/// in all, however many actions name the same stream. +const MAX_SCRIPT_BYTES: usize = 64 << 20; + +/// A budget for the script streams of one reading of the form. +fn script_budget() -> DecodeBudget { + DecodeBudget::new(MAX_SCRIPT_BYTES, 0) +} + +/// The JavaScript of an action dictionary (`/JS` string or stream). A stream is decoded against +/// `budget`, and is `None` once it is spent. +fn script(doc: &Document, action: &Object, budget: &mut DecodeBudget) -> Option { let a = doc.resolve(action); let js = a.as_dict()?.get(b"JS")?.clone(); match &*doc.resolve(&js) { Object::String(s) => Some(s.to_text()), - Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + Object::Stream(s) => budget.decode(s).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), _ => None, } } /// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only /// widget's when the field has none). -fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions { +fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> af::Actions { let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| { (widgets.len() == 1) .then(|| doc.get(widgets[0])) @@ -393,7 +403,7 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions { }); let mut out = af::Actions::default(); let Some(aa) = aa else { return out }; - let js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a)); + let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts)); if let Some(f) = js(b"F") { match af::parse_format(&f) { Some(fm) => out.format = fm, @@ -580,7 +590,7 @@ fn enumerate(doc: &Document) -> (Vec, usize) { None => Inherited::default(), }; let mut out = Vec::new(); - let mut seen = std::collections::HashSet::new(); + let mut seen = Seen { fields: std::collections::HashSet::new(), scripts: script_budget() }; if let Some(af) = &af { for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { if let Some(r) = f.as_ref() { @@ -604,7 +614,7 @@ fn adopt_page_fields( pages: &[ObjRef], base: &Inherited, page_of: &std::collections::HashMap, - seen: &mut std::collections::HashSet, + seen: &mut Seen, out: &mut Vec, ) { for p in pages { @@ -613,7 +623,7 @@ fn adopt_page_fields( let Some(annots) = annots.as_array() else { continue }; for e in annots { let Some(r) = e.as_ref() else { continue }; - if seen.contains(&r) { + if seen.fields.contains(&r) { continue; } let obj = doc.get(r); @@ -624,7 +634,7 @@ fn adopt_page_fields( if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") { continue; } - if let Some(root) = unlisted_root(doc, r, seen) { + if let Some(root) = unlisted_root(doc, r, &seen.fields) { walk(doc, root, base, page_of, seen, out, 0); } } @@ -756,16 +766,23 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re /// Read the field at `r` and the fields under it into `out`, with what they inherit from /// `parent`: kids with a name are fields of their own, kids without are this field's widgets. +/// What one reading of the form's fields has used: the field dictionaries read, and the script +/// budget their actions share. +struct Seen { + fields: std::collections::HashSet, + scripts: DecodeBudget, +} + fn walk( doc: &Document, r: ObjRef, parent: &Inherited, page_of: &std::collections::HashMap, - seen: &mut std::collections::HashSet, + seen: &mut Seen, out: &mut Vec, depth: usize, ) { - if depth > 64 || !seen.insert(r) { + if depth > 64 || !seen.fields.insert(r) { return; } let obj = doc.get(r); @@ -808,9 +825,10 @@ fn walk( return; } let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; - let actions = actions_of(doc, d, &widget_refs); - let button = - (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0).then(|| button_action(doc, d, &widget_refs)).flatten(); + let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts); + let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0) + .then(|| button_action(doc, d, &widget_refs, &mut seen.scripts)) + .flatten(); let ff = inh.ff.unwrap_or(0); let kind = match inh.ft.as_deref() { Some(b"Tx") => FieldKind::Text, diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index f5b8274459..7dd7bfde6c 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -176,6 +176,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() }; let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() }; let mut out: Vec<(String, String)> = Vec::new(); + let mut scripts = crate::script_budget(); // Kids stay unresolved on the stack, and each node is read once: a node listed many times, // or among its own kids, must not multiply the work. let mut stack = vec![tree]; @@ -203,7 +204,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { Object::String(s) => s.to_text(), _ => continue, }; - if let Some(js) = crate::script(doc, v) { + if let Some(js) = crate::script(doc, v, &mut scripts) { out.push((key, js)); } } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b2e4994ecf..b2b8573ebb 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2981,3 +2981,43 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() { assert_eq!(walk.next(), None); assert_eq!(page_refs(&doc), [page]); } + +/// 300 fields and 300 document scripts that all name one 1 MiB JavaScript stream. +fn repeated_script_references() -> Document { + use pdfcraft_cos::Stream; + let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) }; + let mut doc = Document::new_empty(); + let js = doc.add(Stream::flate(pdfcraft_cos::Dict::new(), &vec![b'x'; 1 << 20])); + let action = doc.add(dict(vec![(b"S", Object::Name(b"JavaScript".to_vec())), (b"JS", Object::Ref(js))])); + let fields: Vec = (0..300) + .map(|i| { + Object::Ref(doc.add(dict(vec![ + (b"FT", Object::Name(b"Tx".to_vec())), + (b"T", Object::String(pdfcraft_cos::PdfString::literal(format!("f{i}")))), + (b"Rect", Object::Array([0, 0, 10, 10].map(Object::from).to_vec())), + (b"AA", dict(vec![(b"C", Object::Ref(action))])), + ]))) + }) + .collect(); + let names: Vec = + (0..300).flat_map(|i| [Object::String(pdfcraft_cos::PdfString::literal(format!("s{i:03}"))), Object::Ref(action)]).collect(); + let catalog = doc.add(dict(vec![ + (b"Type", Object::Name(b"Catalog".to_vec())), + (b"AcroForm", dict(vec![(b"Fields", Object::Array(fields))])), + (b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Names", Object::Array(names))]))])), + ])); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_script_references_share_one_decode_budget() { + let doc = repeated_script_references(); + let limit = MAX_SCRIPT_BYTES; + let named: usize = document_scripts_named(&doc).iter().map(|(_, js)| js.len()).sum(); + assert!(named > 0 && named <= limit, "{named} bytes of document scripts"); + let all = fields(&doc); + assert_eq!(all.len(), 300); + let calculate: usize = all.iter().filter_map(|f| f.actions.scripts.calculate.as_ref()).map(String::len).sum(); + assert!(calculate > 0 && calculate <= limit, "{calculate} bytes of field scripts"); +} From d2e7ce72f045bc85e1bcc9e784d43708190a8305 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:17:10 +0000 Subject: [PATCH 092/517] fix(redact): form entries and page content share one budget per page The interpreter entered a form again at every Do with only a nesting limit, so a few shared forms drawing each other produced exponential work in apply, sanitize and verify. Scope carries a per-page DecodeBudget for form entries and form bytes; past it, apply refuses the page and verification counts the form as possible residue. Page content streams are decoded against a page budget too. --- crates/redact/src/interp.rs | 25 +++++++++++++++++++++++-- crates/redact/src/lib.rs | 6 ++++-- crates/redact/src/tests.rs | 17 +++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index c9a404b935..5b37423859 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string}; -use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRef, Object, Stream}; use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; @@ -102,6 +102,9 @@ pub(crate) struct Scope<'a> { fonts: HashMap, Rc>, used_names: Vec>, depth: usize, + /// Form entries and decoded form bytes left for this page: shared forms drawn from other + /// shared forms would otherwise multiply the work with every level. + budget: DecodeBudget, } impl<'a> Scope<'a> { @@ -120,6 +123,7 @@ impl<'a> Scope<'a> { fonts: HashMap::new(), used_names: Vec::new(), depth: 0, + budget: DecodeBudget::page(), } } @@ -1201,8 +1205,25 @@ fn xobject( out.doomed.push(r); return Some(None); } - let data = match s.decoded() { + let data = if scope.budget.enter_form() { + scope.budget.decode(s) + } else { + Err(CosError::Filter(format!("the page draws more than {MAX_PAGE_FORMS} form XObjects"))) + }; + let data = match data { Ok(d) => d, + // Past the page's budget nothing more is read: apply refuses the page, and + // verification counts the form as content that may remain. + Err(e) if scope.budget.exhausted() => { + if scope.mode == Mode::Apply { + out.error = Some(e); + } else if scope.everywhere() { + scope.layer_blocks += 1; + } else { + out.residue += 1; + } + return None; + } Err(_) if scope.mode == Mode::Apply => { scope.report.forms_removed += 1; out.replaced.push(name); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6df5102afa..8407e0c8f8 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -219,7 +219,8 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec, Vec) { (out, unreadable) } -/// A page's content streams: (the references or inline objects as listed, their decoded data). +/// A page's content streams: (the references or inline objects as listed, their decoded data), +/// decoded within one page's [`pdfcraft_cos::DecodeBudget`]. pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec, Vec>), RedactError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), @@ -229,9 +230,10 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result< }, }; let mut data = Vec::new(); + let mut budget = pdfcraft_cos::DecodeBudget::page(); for o in &list { match &*doc.resolve(o) { - Object::Stream(s) => data.push(s.decoded().map_err(|_| RedactError::Unreadable(index + 1))?), + Object::Stream(s) => data.push(budget.decode(s).map_err(|_| RedactError::Unreadable(index + 1))?), Object::Null => data.push(Vec::new()), _ => return Err(RedactError::Unreadable(index + 1)), } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 286667937d..11cae9bb92 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2472,3 +2472,20 @@ fn a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_under_a_mark() { assert_eq!(under(&mut doc, 0, &[[45.0, 95.0, 50.0, 110.0]]), 0, "nothing is drawn past LASTARR's 35pt width"); assert_eq!(under(&mut doc, 0, &[[10.0, 95.0, 45.0, 110.0]]), 7, "LASTARR's 7 glyphs, the one the page draws"); } + +#[test] +fn form_entries_share_one_budget_per_page() { + // 20 draws of a form that draws the next 20 times, four levels down: 160,000 glyphs from a + // few hundred bytes. + let draws = "/X Do ".repeat(20); + let mut objs = vec![stream("/Type /XObject /Subtype /Form /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 10 100 Td (A) Tj ET")]; + for inner in 7..10 { + objs.push(stream(&format!("/Type /XObject /Subtype /Form /Resources << /XObject << /X {inner} 0 R >> >>"), draws.as_bytes())); + } + let mut doc = one_page(draws.as_bytes(), "/XObject << /X 10 0 R >>", objs); + let residue = under(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]]); + assert!(residue > 0 && residue <= pdfcraft_cos::MAX_PAGE_FORMS, "{residue} glyphs counted"); + // Applying refuses the page rather than leave forms past the budget unread. + mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], ""); + assert!(apply(&mut doc, None).is_err()); +} From ea4939cd08823bdd390797f5c245fe55bfb0c6e5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:17:54 +0000 Subject: [PATCH 093/517] fix(cos): a strict decode charged to a DecodeBudget The redaction audit treats any damaged stream as a failure, so it needs the strict decoder under the same shared allowance. --- crates/cos/src/budget.rs | 16 +++++++++++++++- crates/cos/src/object.rs | 21 ++++++++++++++------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 35319f13bb..cf1c3fc2a1 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -53,10 +53,20 @@ impl DecodeBudget { /// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without /// decoding, once the allowance is spent. pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { + self.charge(stream, Stream::decoded_within_typed) + } + + /// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to + /// the allowance. + pub fn decode_strict(&mut self, stream: &Stream) -> Result, CosError> { + self.charge(stream, Stream::decoded_strict_within_typed) + } + + fn charge(&mut self, stream: &Stream, decode: fn(&Stream, usize) -> Result, FilterError>) -> Result, CosError> { if self.bytes == 0 && !stream.raw.is_empty() { return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } - match stream.decoded_within_typed(self.bytes) { + match decode(stream, self.bytes) { Ok(data) => { self.bytes = self.bytes.saturating_sub(data.len()); Ok(data) @@ -127,6 +137,10 @@ mod tests { let mut budget = DecodeBudget::new((1 << 20) - 1, 1); assert!(budget.decode_contents(&doc, &contents).iter().all(Result::is_err)); assert_eq!(budget.remaining(), 0); + let mut budget = DecodeBudget::new((1 << 20) - 1, 1); + let Object::Stream(stream) = &*doc.get(bomb) else { panic!("not a stream") }; + assert!(budget.decode_strict(stream).is_err()); + assert_eq!(budget.remaining(), 0); assert!(budget.enter_form()); assert!(!budget.enter_form()); diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index fe33388be3..9a07d4822e 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -223,21 +223,24 @@ impl Stream { let max = max.min(MAX_DECODED); let chain = self.filters(); if chain.is_empty() { - if self.raw.len() > max { - return Err(pdfcraft_filters::FilterError::LimitExceeded(max)); - } - return Ok(self.raw.to_vec()); + return self.raw_within_typed(max); } pdfcraft_filters::decode_tolerant(&chain, &self.raw, max).map(|(v, _)| v) } /// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error. pub fn decoded_strict(&self) -> Result, CosError> { + self.decoded_strict_within_typed(MAX_DECODED).map_err(|e| CosError::Filter(e.to_string())) + } + + /// [`Stream::decoded_strict`] bounded by `max`, with the filter error kept typed. + pub(crate) fn decoded_strict_within_typed(&self, max: usize) -> Result, pdfcraft_filters::FilterError> { + let max = max.min(MAX_DECODED); let chain = self.filters(); if chain.is_empty() { - return self.raw_within(MAX_DECODED); + return self.raw_within_typed(max); } - pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string())) + pdfcraft_filters::decode(&chain, &self.raw, max) } /// Data after the first filter alone, bounded by `max`: the rest of the chain stays encoded. @@ -252,11 +255,15 @@ impl Stream { } fn raw_within(&self, max: usize) -> Result, CosError> { + self.raw_within_typed(max).map_err(|e| CosError::Filter(e.to_string())) + } + + fn raw_within_typed(&self, max: usize) -> Result, pdfcraft_filters::FilterError> { let max = max.min(MAX_DECODED); // An empty decoder chain still produces an owned output buffer: check its limit // before cloning, including streams whose /Crypt filter was applied on load. if self.raw.len() > max { - return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string())); + return Err(pdfcraft_filters::FilterError::LimitExceeded(max)); } Ok(self.raw.to_vec()) } From b5cddc884d7bef1a724a339e0e91c5fd5264b904 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:18:42 +0000 Subject: [PATCH 094/517] fix(audit): page content and forms share one decode budget per page The cover walk decoded every /Contents entry and every form draw with a fresh 1 GiB allowance; the operator bound does not count whitespace, so a large blank form drawn many times meant hours of decoding. The walk charges a per-page DecodeBudget with the strict decoder, and spending it fails the audit. --- crates/audit/src/cover.rs | 14 +++++++++----- crates/audit/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 3df666c190..516bb193d2 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -16,7 +16,7 @@ //! Positions compare lexicographically in paint order. use pdfcraft_content::{Matrix, Op, Pieces, parse}; -use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; use crate::AuditError; @@ -109,6 +109,8 @@ struct Walker<'a> { pieces: &'a Pieces, out: Vec, ops_seen: usize, + /// Decoded bytes left for the page, its forms included: spending it fails the audit. + budget: DecodeBudget, } impl<'a> Walker<'a> { @@ -272,7 +274,7 @@ impl<'a> Walker<'a> { .unwrap_or(Matrix::IDENTITY); let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); // A form stream that cannot be decoded fails the audit: it might hide a cover. - let data = stream.decoded_strict()?; + let data = self.budget.decode_strict(stream)?; let ops = parse(&data).ops; // The XObject matrix maps form space into the user space active at `Do`. let mut sub_gs = gs.clone(); @@ -304,7 +306,8 @@ fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { /// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint /// positions. A page's content streams are walked once, joined, with one graphics state, so /// state set in an earlier stream is still in force in the later ones. Any content stream that -/// cannot be decoded is an audit failure, never an empty result. +/// cannot be decoded, or content and forms that decode past one page's [`DecodeBudget`] in all, +/// is an audit failure, never an empty result. pub fn filled_rects(doc: &Document, page: &Dict) -> Result, AuditError> { let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let contents: Vec = match page.get(b"Contents") { @@ -314,15 +317,16 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result, Audi _ => vec![c.clone()], }, }; + let mut budget = DecodeBudget::page(); let mut streams = Vec::with_capacity(contents.len()); for c in &contents { if let Object::Stream(s) = &*doc.resolve(c) { - streams.push(s.decoded_strict()?); + streams.push(budget.decode_strict(s)?); } } let pieces = Pieces::join(&streams); let parsed = pieces.parse(); - let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0 }; + let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget }; let mut gs = Gs::default(); let mut stack = Vec::new(); w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?; diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 4ca3b0bf5b..8155c6dece 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -592,3 +592,22 @@ fn reading_blocks_respect_stream_comments_and_split_operands() { assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1)); } } + +#[test] +fn repeated_stream_references_share_one_decode_budget() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + // One 1 MiB stream of spaces (a kilobyte on disk), named 300 times: by the page's /Contents, + // and by 300 draws of a form holding it. + let mut doc = Document::new_empty(); + let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let contents: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(spaces); 300]))].into_iter().collect(); + assert!(crate::cover::filled_rects(&doc, &contents).is_err(), "repeated content streams"); + + let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::flate(form_dict, &vec![b' '; 1 << 20])); + let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(300).into_bytes())); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); + assert!(crate::cover::filled_rects(&doc, &page).is_err(), "repeated form draws"); +} From a37a908f7b56539dfb843c756b301af9577c27e8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:22:17 +0000 Subject: [PATCH 095/517] fix(edit): page content, forms and image records share per-page limits The editor's page readers decoded every /Contents entry and every form entry with a fresh 1 GiB allowance, and the reading-order image inventory added a record per image draw in every form entered, so a few shared forms drawing each other produced millions of records. Content streams and form entries are decoded against one per-page DecodeBudget (content past it is an error, so no edit rewrites a page from part of its content; forms past it are reported truncated), and an inventory past 100,000 image draws is refused. --- crates/edit/src/background.rs | 2 +- crates/edit/src/images.rs | 43 +++++++++---------- crates/edit/src/tests.rs | 75 +++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 72 +++++++++++++++++++------------ crates/edit/src/text_regions.rs | 2 +- 5 files changed, 143 insertions(+), 51 deletions(-) diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs index d1d50e4502..7d745c0c10 100644 --- a/crates/edit/src/background.rs +++ b/crates/edit/src/background.rs @@ -50,7 +50,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE let crop = p.crop(doc); let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); - let streams = crate::text::content_streams(doc, &p.dict); + let streams = crate::text::content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 75bb12f47d..0f75ff3d3c 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -73,23 +73,16 @@ struct ClipPath { even_odd: bool, } -fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { - let list: Vec = match page.get(b"Contents") { - None => Vec::new(), - Some(c) => match &*doc.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }, - }; - list.into_iter() - .map(|o| { - let data = match &*doc.resolve(&o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }; - (o, data) - }) - .collect() +fn streams(doc: &Document, page: &Dict) -> Result)>, EditError> { + crate::text::content_streams(doc, page) +} + +/// Image draws one page's inventory lists at most: a form drawn many times from other forms +/// would otherwise multiply a few hundred bytes into millions of records. +pub(crate) const MAX_PAGE_IMAGES: usize = 100_000; + +fn too_many_images() -> EditError { + EditError::Invalid(format!("the page draws more than {MAX_PAGE_IMAGES} images")) } /// The page's streams joined into the one stream they are: an operator may span two of them. @@ -237,7 +230,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr let mut out = Vec::new(); // The streams are one stream in pieces, so the graphics state and operators carry over. let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let pieces = joined(&streams(doc, &p.dict)); + let pieces = joined(&streams(doc, &p.dict)?); for (i, op) in pieces.parse().ops.iter().enumerate() { state.apply(op)?; let ctm = state.placement.matrix; @@ -285,6 +278,9 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr { continue; } + if out.len() >= MAX_PAGE_IMAGES { + return Err(too_many_images()); + } out.push(PageImage { rect, visible_rect: intersection(rect, state.placement.clip), @@ -358,6 +354,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi if s.dict.name(b"Subtype") != Some(b"Image") { continue; } + if out.len() >= MAX_PAGE_IMAGES { + return Err(too_many_images()); + } out.push(PageImage { rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]), visible_rect: intersection(ctm.bbox([0.0, 0.0, 1.0, 1.0]), st.placement.clip), @@ -382,7 +381,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() }; - let pieces = joined(&streams(doc, &p.dict)); + let pieces = joined(&streams(doc, &p.dict)?); walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) } @@ -483,7 +482,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let images = page_images(doc, page)?; let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = streams(doc, &p.dict)?; let pieces = joined(&all); let ops = pieces.parse().ops; if !ops.get(img.op).is_some_and(|op| op.is("Do")) { @@ -531,7 +530,7 @@ pub(crate) fn selectable_images( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = streams(doc, &p.dict)?; Ok(page_images(doc, page)? .into_iter() .enumerate() @@ -755,7 +754,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize } let images = page_images(doc, page)?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = streams(doc, &p.dict)?; let mut plans = std::collections::BTreeMap::new(); for index in indexes { let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index cf3860237b..61e5e958ed 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6355,3 +6355,78 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() { ] ); } + +/// A one-page document from the page's /Contents and /Resources, built in code. +fn page_with(build: impl FnOnce(&mut Document) -> (Object, Dict)) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.add(Object::Null); + let (contents, resources) = build(&mut doc); + let page: Dict = [ + (b"Type".to_vec(), Object::Name(b"Page".to_vec())), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())), + (b"Contents".to_vec(), contents), + (b"Resources".to_vec(), Object::Dict(resources)), + ] + .into_iter() + .collect(); + let page = doc.add(page); + let tree: Dict = [ + (b"Type".to_vec(), Object::Name(b"Pages".to_vec())), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(); + doc.set(pages, Object::Dict(tree)); + let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect(); + let catalog = doc.add(catalog); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_content_references_share_one_decode_budget() { + // One 1 MiB stream (a kilobyte on disk) named 300 times by the page's /Contents. + let doc = page_with(|doc| { + let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + (Object::Array(vec![Object::Ref(spaces); 300]), Dict::new()) + }); + match text_lines(&doc, 0) { + Ok(lines) => panic!("read {} lines from more content than a page may decode", lines.len()), + Err(e) => assert!(matches!(e, EditError::Invalid(_)), "{e}"), + } +} + +#[test] +fn reading_images_stops_at_the_page_record_limit() { + // 20 draws of a form drawing a form 20 times that draws an image 20 times: 8,000 images + // per level-two form, 160,000 in all, from a few hundred bytes. + let doc = page_with(|doc| { + let image: Dict = [ + (b"Subtype".to_vec(), Object::Name(b"Image".to_vec())), + (b"Width".to_vec(), Object::Int(1)), + (b"Height".to_vec(), Object::Int(1)), + (b"ColorSpace".to_vec(), Object::Name(b"DeviceGray".to_vec())), + (b"BitsPerComponent".to_vec(), Object::Int(8)), + ] + .into_iter() + .collect(); + let mut inner = doc.add(Stream::from_raw(image, vec![0])); + let draws = "/X Do ".repeat(20); + let resources = |inner: pdfcraft_cos::ObjRef| -> Dict { + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect() + }; + for _ in 0..3 { + let mut d = resources(inner); + d = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec())), (b"Resources".to_vec(), Object::Dict(d))].into_iter().collect(); + inner = doc.add(Stream::from_raw(d, draws.clone().into_bytes())); + } + let page = doc.add(Stream::from_raw(Dict::new(), draws.into_bytes())); + (Object::Ref(page), resources(inner)) + }); + if let Ok(images) = crate::reading_images(&doc, 0) { + assert!(images.len() <= crate::images::MAX_PAGE_IMAGES, "{} images", images.len()); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 93a5eec99e..5784b95f3b 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; -use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, PdfString, Stream}; use pdfcraft_fonts::pdf::Metrics; use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from}; @@ -615,7 +615,10 @@ struct Ts { unreadable: Option>, } -pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { +/// The page's content streams as listed, each with its decoded data (empty when it doesn't +/// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading +/// or rewriting a page from part of its content would lose the rest. +pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result)>, EditError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -623,15 +626,20 @@ pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec vec![c.clone()], }, }; - list.into_iter() - .map(|o| { - let data = match &*doc.resolve(&o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }; - (o, data) - }) - .collect() + let mut budget = DecodeBudget::page(); + let mut out = Vec::with_capacity(list.len()); + for o in list { + let data = match &*doc.resolve(&o) { + Object::Stream(s) => match budget.decode(s) { + Ok(data) => data, + Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))), + Err(_) => Vec::new(), + }, + _ => Vec::new(), + }; + out.push((o, data)); + } + Ok(out) } /// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators @@ -1284,7 +1292,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro let mut carry = Carry::new(); // The streams are one stream in pieces (§7.8.2), split between any two tokens: parse them // joined, so an operator whose operands end one piece still has them. - let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); + let pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::>()); let ops = pieces.parse().ops; let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default(); let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, Some(&properties))?; @@ -1490,10 +1498,11 @@ pub const MAX_FORM_DEPTH: usize = 50; pub const MAX_FORM_VISITS: usize = 50_000; /// One page's walk through the form XObjects it draws: how many it entered (the page's -/// [`MAX_FORM_VISITS`]), and whether a limit left a form unread. -#[derive(Default)] +/// [`MAX_FORM_VISITS`]), the bytes their content may decode to in all, and whether a limit left +/// a form unread. pub(crate) struct FormBudget { visits: usize, + decode: DecodeBudget, /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past /// [`MAX_FORM_VISITS`]: what it draws is missing from the walk. pub(crate) truncated: bool, @@ -1501,6 +1510,12 @@ pub(crate) struct FormBudget { /// A form XObject a page draws: its object, content, resources and its matrix composed with the /// CTM at the `Do`. +impl Default for FormBudget { + fn default() -> Self { + Self { visits: 0, decode: DecodeBudget::page(), truncated: false } + } +} + pub(crate) struct FormCall { pub(crate) obj: Option, pub(crate) data: Vec, @@ -1519,7 +1534,7 @@ pub(crate) fn form_call( path: &[pdfcraft_cos::ObjRef], budget: &mut FormBudget, ) -> Option { - let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS; + let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS || budget.decode.remaining() == 0; // Once a limit has cut the page short, refusing the rest costs nothing more. if limited && budget.truncated { return None; @@ -1546,13 +1561,16 @@ pub(crate) fn form_call( }); // A form without its own resources uses the ones it was drawn with (PDF 1.1 files). let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let data = match budget.decode.decode(s) { + Ok(data) => data, + Err(_) if budget.decode.remaining() == 0 => { + budget.truncated = true; + return None; + } + Err(_) => Vec::new(), + }; budget.visits = budget.visits.saturating_add(1); - Some(FormCall { - obj, - data: s.decoded().unwrap_or_default(), - resources: own.unwrap_or_else(|| resources.clone()), - ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)), - }) + Some(FormCall { obj, data, resources: own.unwrap_or_else(|| resources.clone()), ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) }) } /// The lines a page shows as it reads, including text drawn by form XObjects (which @@ -1612,7 +1630,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E } let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() }; let mut carry = Carry::new(); // Read the page's streams as the one stream they are, in one pass, as rendering and editing @@ -2235,7 +2253,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let p = page_dict(doc, page)?; let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?; @@ -2440,7 +2458,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied())); let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?; @@ -2661,7 +2679,7 @@ fn rewrite_block_from( { return rewrite_styled_block(doc, page, &b, &lines, text, style); } - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; // Explicit line breaks stay (#1443); within a line, runs of white space become one space. @@ -3044,7 +3062,7 @@ pub(crate) fn selectable_blocks( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_dict(doc, page)?; - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let lines = text_lines(doc, page)?; Ok(group_blocks(&lines) .into_iter() @@ -3133,7 +3151,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize return Ok(()); } let p = page_dict(doc, page)?; - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let lines = text_lines(doc, page)?; let blocks = group_blocks(&lines); let mut selected = std::collections::BTreeSet::new(); diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 928280d5d8..a775594944 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -772,7 +772,7 @@ pub(super) fn rewrite_styled_block( row_ranges.push(row_start..row_start + row.len()); rows.push(row); } - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and From 735c80c6c51975b6ba57ec2fa8ced1768bbba848 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:22:55 +0000 Subject: [PATCH 096/517] fix(cos): DecodeBudget decodes within a per-stream bound too Callers with a tighter limit for one stream (an embedded font program) need it alongside the shared allowance. A decode that runs past that bound charges the bound, the work it took, so repeated failures still spend the allowance. --- crates/cos/src/budget.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index cf1c3fc2a1..f88971222a 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -53,19 +53,35 @@ impl DecodeBudget { /// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without /// decoding, once the allowance is spent. pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { - self.charge(stream, Stream::decoded_within_typed) + self.charge(stream, usize::MAX, Stream::decoded_within_typed) + } + + /// Like [`DecodeBudget::decode`], for a stream with a tighter bound of its own: decoding + /// past `max` bytes is an error that charges `max`, the work it took to find out. + pub fn decode_within(&mut self, stream: &Stream, max: usize) -> Result, CosError> { + self.charge(stream, max, Stream::decoded_within_typed) } /// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to /// the allowance. pub fn decode_strict(&mut self, stream: &Stream) -> Result, CosError> { - self.charge(stream, Stream::decoded_strict_within_typed) + self.charge(stream, usize::MAX, Stream::decoded_strict_within_typed) } - fn charge(&mut self, stream: &Stream, decode: fn(&Stream, usize) -> Result, FilterError>) -> Result, CosError> { + fn charge(&mut self, stream: &Stream, max: usize, decode: fn(&Stream, usize) -> Result, FilterError>) -> Result, CosError> { if self.bytes == 0 && !stream.raw.is_empty() { return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } + if max < self.bytes { + let result = decode(stream, max); + let spent = match &result { + Ok(data) => data.len(), + Err(FilterError::LimitExceeded(_)) => max, + Err(_) => 0, + }; + self.bytes = self.bytes.saturating_sub(spent); + return result.map_err(|e| CosError::Filter(e.to_string())); + } match decode(stream, self.bytes) { Ok(data) => { self.bytes = self.bytes.saturating_sub(data.len()); @@ -142,6 +158,12 @@ mod tests { assert!(budget.decode_strict(stream).is_err()); assert_eq!(budget.remaining(), 0); + let mut budget = DecodeBudget::new(10 << 20, 1); + assert!(budget.decode_within(stream, 1000).is_err()); + assert_eq!(budget.remaining(), (10 << 20) - 1000); + assert_eq!(budget.decode_within(stream, 2 << 20).map(|d| d.len()).ok(), Some(1 << 20)); + assert_eq!(budget.remaining(), (9 << 20) - 1000); + assert!(budget.enter_form()); assert!(!budget.enter_form()); assert!(budget.first_visit(bomb)); From 7a33a85d8b95b98fb1310258f1e104bd4287ecf4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:24:00 +0000 Subject: [PATCH 097/517] fix(fonts): embedded sfnt reads can share one decode budget embedded_sfnt decoded each program with a fresh 16 MiB allowance and charged nothing when the program turned out unusable, so a page whose font resources all named one bad program inflated it once per alias. embedded_sfnt_within charges every decode to a caller's DecodeBudget. --- crates/fonts/src/pdf.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0aaed6939e..1992116651 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -14,7 +14,7 @@ use std::borrow::Cow; use std::collections::{BinaryHeap, HashMap, HashSet}; -use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; /// How the bytes of a string map to codes. #[derive(Clone, Debug, PartialEq)] @@ -92,6 +92,15 @@ fn legacy_indic_font(base_font: &str) -> bool { /// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export /// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer. pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { + embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0)) +} + +/// The largest embedded sfnt program read for the editor. +const MAX_SFNT: usize = 16 << 20; + +/// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares: +/// each program decoded is charged whether or not it turns out to be usable. +pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option> { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref()); let descriptor = dict(doc, descendant.as_ref().unwrap_or(font).get(b"FontDescriptor"))?; @@ -99,7 +108,7 @@ pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(mut bytes) = stream.decoded_within(16 << 20) else { continue }; + let Ok(mut bytes) = budget.decode_within(stream, MAX_SFNT) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 { continue; @@ -2114,4 +2123,20 @@ mod encoding_tests { assert_eq!(m.text_of(0x61).as_deref(), Some("a")); assert_eq!(m.decode(b"abc"), "abc"); } + + #[test] + fn unusable_programs_still_spend_the_decode_budget() { + // 128 resources naming one 1 MiB program that isn't a font: each decode is charged, + // so an enumeration stops decoding once the shared budget is spent. + let mut doc = Document::new_empty(); + let program = doc.add(pdfcraft_cos::Stream::flate(Dict::new(), &vec![0; 1 << 20])); + let descriptor: Dict = [(b"FontFile2".to_vec(), Object::Ref(program))].into_iter().collect(); + let descriptor = doc.add(descriptor); + let font: Dict = [(b"FontDescriptor".to_vec(), Object::Ref(descriptor))].into_iter().collect(); + let mut budget = DecodeBudget::new(64 << 20, 0); + for _ in 0..128 { + assert!(embedded_sfnt_within(&doc, &font, &mut budget).is_none()); + } + assert_eq!(budget.remaining(), 0); + } } From c88831381e14ab461ea5cc96881a48e88d5d0d87 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:24:00 +0000 Subject: [PATCH 098/517] fix(edit): editor font enumeration shares one decode budget text_editor_fonts counted only the programs it kept against its 32 MiB budget, so up to 128 aliases of one unusable program were each inflated in full. Every decode now spends a shared 64 MiB allowance. --- crates/edit/src/text.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 5784b95f3b..34768527b3 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -113,10 +113,12 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result budget { continue; } From 37b6c530d82d4351f3598010fd5262e98bbb6882 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:25:04 +0000 Subject: [PATCH 099/517] refactor(edit): images read page content through content_streams The image module kept its own copy of the page-content reader; the one in text carries the page decode budget. --- crates/edit/src/images.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 0f75ff3d3c..1f7773a52c 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -9,6 +9,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use std::sync::Arc; use crate::EditError; +use crate::text::content_streams; /// An image drawn on a page. #[derive(Clone, Debug, PartialEq)] @@ -73,10 +74,6 @@ struct ClipPath { even_odd: bool, } -fn streams(doc: &Document, page: &Dict) -> Result)>, EditError> { - crate::text::content_streams(doc, page) -} - /// Image draws one page's inventory lists at most: a form drawn many times from other forms /// would otherwise multiply a few hundred bytes into millions of records. pub(crate) const MAX_PAGE_IMAGES: usize = 100_000; @@ -230,7 +227,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr let mut out = Vec::new(); // The streams are one stream in pieces, so the graphics state and operators carry over. let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let pieces = joined(&streams(doc, &p.dict)?); + let pieces = joined(&content_streams(doc, &p.dict)?); for (i, op) in pieces.parse().ops.iter().enumerate() { state.apply(op)?; let ctm = state.placement.matrix; @@ -381,7 +378,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() }; - let pieces = joined(&streams(doc, &p.dict)?); + let pieces = joined(&content_streams(doc, &p.dict)?); walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) } @@ -482,7 +479,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let images = page_images(doc, page)?; let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict)?; + let all = content_streams(doc, &p.dict)?; let pieces = joined(&all); let ops = pieces.parse().ops; if !ops.get(img.op).is_some_and(|op| op.is("Do")) { @@ -530,7 +527,7 @@ pub(crate) fn selectable_images( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_of(doc, page)?; - let all = streams(doc, &p.dict)?; + let all = content_streams(doc, &p.dict)?; Ok(page_images(doc, page)? .into_iter() .enumerate() @@ -754,7 +751,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize } let images = page_images(doc, page)?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict)?; + let all = content_streams(doc, &p.dict)?; let mut plans = std::collections::BTreeMap::new(); for index in indexes { let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?; From 986a64bc77a2c4725b72e06d057475833ab4737b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:05:32 +0000 Subject: [PATCH 100/517] fix(cos): cap the objects one parsed object may build The parser pushed every array element and dictionary value with only a nesting limit, so a small compressed object stream decoding to a long run of '0 ' became tens of millions of Object values. Each top-level parse now counts the objects it builds and fails past 4M, including inside tolerated junk. --- crates/cos/src/parser.rs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 0458055961..c8b90106cf 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -10,6 +10,9 @@ use crate::object::{Dict, ObjRef, Object, PdfString, Stream}; use crate::{Bytes, CosError}; const MAX_DEPTH: usize = 128; +/// Objects one top-level [`Lexer::object`] call may build. Real arrays (page trees, parent +/// trees, widths) stay far below; a decompressed object stream of `0 0 0 ...` does not. +const MAX_NODES: usize = 1 << 22; /// One of the six PDF whitespace characters (ISO 32000-2 §7.2.3, Table 1). pub fn is_whitespace(b: u8) -> bool { @@ -28,11 +31,13 @@ fn is_regular(b: u8) -> bool { pub struct Lexer<'a> { pub data: &'a [u8], pub pos: usize, + /// Objects built by the current top-level [`Lexer::object`] call. + nodes: usize, } impl<'a> Lexer<'a> { pub fn new(data: &'a [u8], pos: usize) -> Self { - Self { data, pos: pos.min(data.len()) } + Self { data, pos: pos.min(data.len()), nodes: 0 } } fn peek(&self) -> Option { @@ -84,13 +89,22 @@ impl<'a> Lexer<'a> { /// Parse one direct object. References `N G R` are recognised. pub fn object(&mut self) -> Result { + self.nodes = 0; self.object_depth(0) } + fn over_budget(&self) -> bool { + self.nodes > MAX_NODES + } + fn object_depth(&mut self, depth: usize) -> Result { if depth > MAX_DEPTH { return Err(self.err("nesting too deep")); } + self.nodes = self.nodes.saturating_add(1); + if self.over_budget() { + return Err(self.err("object too large")); + } self.skip_ws(); let Some(b) = self.peek() else { return Err(self.err("unexpected end of data")) }; match b { @@ -125,6 +139,7 @@ impl<'a> Lexer<'a> { let before = self.pos; match self.object_depth(depth + 1) { Ok(o) => items.push(o), + Err(e) if self.over_budget() => return Err(e), // Tolerate junk inside arrays by skipping one byte. Err(_) if self.pos == before => self.pos += 1, Err(e) => return Err(e), @@ -283,6 +298,9 @@ impl<'a> Lexer<'a> { // Tolerate garbage between entries (seen in damaged files): skip a token. let before = self.pos; let _ = self.object_depth(depth + 1); + if self.over_budget() { + return Err(self.err("object too large")); + } if self.pos == before { self.pos += 1; } @@ -598,4 +616,20 @@ mod tests { let deep = "[".repeat(10_000); assert!(Lexer::new(deep.as_bytes(), 0).object().is_err()); } + + #[test] + fn one_object_has_a_node_budget() { + // A wide array (from a tiny compressed object stream) must not grow without bound. + let wide = |n: usize| format!("[{}]", "0 ".repeat(n)); + let at_cap = Lexer::new(wide(MAX_NODES - 1).as_bytes(), 0).object(); + assert!(matches!(at_cap, Ok(Object::Array(ref items)) if items.len() == MAX_NODES - 1)); + assert!(Lexer::new(wide(MAX_NODES).as_bytes(), 0).object().is_err()); + // The same inside a dictionary value and behind tolerated junk. + assert!(Lexer::new(format!("<< /A {} >>", wide(MAX_NODES)).as_bytes(), 0).object().is_err()); + assert!(Lexer::new(format!("[[{}] ]", "0 ".repeat(MAX_NODES)).as_bytes(), 0).object().is_err()); + // The budget is per top-level object: a lexer reused for many operands keeps working. + let two = wide(MAX_NODES - 1).repeat(2); + let mut lx = Lexer::new(two.as_bytes(), 0); + assert!(lx.object().is_ok() && lx.object().is_ok()); + } } From e07942c8294301cf0ee5ad911d7dc3016bd33c50 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:01 +0000 Subject: [PATCH 101/517] fix(annot): cap the bumps of a cloudy outline The cloud border clamped bumps per edge only, so a polygon with many long edges asked for 10,000 cubic segments on each one. The bump radius now grows so the whole outline draws at most 20,000 bumps plus one per edge. --- crates/annot/src/appearance.rs | 6 ++++++ crates/annot/src/tests.rs | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 4668881a6c..59cacbbdd9 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -120,12 +120,18 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String { smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect() } +/// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow. +pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000; + /// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of /// half-circle bumps of about `r`, bulging outwards. pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String { let area: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.0 * b.1 - b.0 * a.1).sum(); // Outward normal of an edge: to the right of travel for counter-clockwise outlines. let sign = if area >= 0.0 { 1.0 } else { -1.0 }; + let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum(); + // Each edge rounds to at least one bump, so the total stays within the budget plus one per edge. + let r = r.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64)); let mut c = String::new(); for (i, (a, b)) in pts.iter().zip(pts.iter().cycle().skip(1)).enumerate() { let (dx, dy) = (b.0 - a.0, b.1 - a.1); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a659966812..201fe0e086 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1504,3 +1504,11 @@ fn typed_signature_uses_nonzero_winding_fill() { assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}"); assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); } + +#[test] +fn cloud_outline_has_a_bump_budget() { + // 50 long edges would each ask for 10,000 bumps of radius 1. + let pts: Vec<(f64, f64)> = (0..50).map(|i| (f64::from(i) * std::f64::consts::TAU / 50.0).sin_cos()).map(|(s, c)| (1e7 * c, 1e7 * s)).collect(); + let bumps = appearance::cloud_path(&pts, 1.0).lines().filter(|l| l.ends_with(" c")).count(); + assert!((pts.len()..=appearance::MAX_CLOUD_BUMPS + 2 * pts.len()).contains(&bumps), "{bumps} bumps"); +} From 1695106f98e044cab8892c3b048ad6155f316027 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:01 +0000 Subject: [PATCH 102/517] fix(annot): list a repeated link once and cap its web-address text The link listing copied the action's URI into a new String for every /Annots entry, so one annotation referenced many times, or many small links sharing one long URI string, multiplied that string in memory. The listing skips annotation references it has already listed and keeps at most 16 MiB of web-address text; a link past that lists as an uneditable action, which leaves it unchanged in the file. --- crates/annot/src/links.rs | 29 ++++++++++++++++++++++++----- crates/annot/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 4d3eef632b..b6a2c7575c 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -78,11 +78,14 @@ pub struct LinkItem { pub style: LinkStyle, } +/// Web-address text one listing keeps; past it a link lists as [`LinkAction::Other`]. +pub(crate) const MAX_URI_BYTES: usize = 16 << 20; + fn nums(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() } -fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction { +fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction { let dest_page = |dest: &Object| -> Option { let a = doc.resolve(dest); let first = a.as_array()?.first()?.clone(); @@ -97,9 +100,17 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction { } let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) }; match a.name(b"S") { - Some(b"URI") => LinkAction::Uri( - a.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned())).unwrap_or_default(), - ), + Some(b"URI") => { + let uri = a.get(b"URI").map(|u| doc.resolve(u)); + let bytes = uri.as_ref().and_then(|u| u.as_string()).map_or(&[][..], |s| &s.bytes[..]); + match uri_budget.checked_sub(bytes.len()) { + Some(left) => { + *uri_budget = left; + LinkAction::Uri(String::from_utf8_lossy(bytes).into_owned()) + } + None => LinkAction::Other("web address too long".into()), + } + } Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page), Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()), None => LinkAction::Other("no action".into()), @@ -133,8 +144,16 @@ fn style_of(doc: &Document, d: &Dict) -> LinkStyle { pub fn list(doc: &Document) -> Vec { let Ok(pages) = page_refs(doc) else { return Vec::new() }; let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut uri_budget = MAX_URI_BYTES; for (pi, p) in pages.iter().enumerate() { for (k, a) in annots(doc, *p).iter().enumerate() { + // One annotation object listed under several entries or pages is one link. + if let Some(r) = a.as_ref() + && !seen.insert(r) + { + continue; + } let Some(d) = doc.resolve(a).as_dict().cloned() else { continue }; if d.name(b"Subtype") != Some(b"Link") { continue; @@ -148,7 +167,7 @@ pub fn list(doc: &Document) -> Vec { index: k, obj: a.as_ref(), rect: [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])], - action: action_of(doc, &d, &pages), + action: action_of(doc, &d, &pages, &mut uri_budget), style: style_of(doc, &d), }); } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 201fe0e086..c52732ea85 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1505,6 +1505,42 @@ fn typed_signature_uses_nonzero_winding_fill() { assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); } +fn doc_of(objs: &[String]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") +} + +#[test] +fn link_listing_skips_repeated_refs_and_caps_web_address_text() { + // One link referenced 1000 times, and 300 small links sharing one 64 KiB address. + let annots = format!("{}{}", " 4 0 R".repeat(1000), "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >>".repeat(300)); + let doc = doc_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A 5 0 R >>".into(), + "<< /S /URI /URI 6 0 R >>".into(), + format!("({})", "u".repeat(64 << 10)), + ]); + let all = links::list(&doc); + assert_eq!(all.iter().filter(|l| l.obj == Some(ObjRef::new(4, 0))).count(), 1); + assert_eq!(all.len(), 301, "every distinct link is still listed"); + let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum(); + assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept"); + assert!(matches!(&all[0].action, links::LinkAction::Uri(u) if u.len() == 64 << 10)); +} + #[test] fn cloud_outline_has_a_bump_budget() { // 50 long edges would each ask for 10,000 bumps of radius 1. From 83d8eb085603a6511afca93b82ec1bb42a03eed9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:44 +0000 Subject: [PATCH 103/517] fix(create): decode an indexed lookup table within a small cap Image export decoded a stream lookup table under the general 1 GiB stream limit although a table holds at most 1024 bytes, and every image sharing the palette decoded it again. The table now decodes within 64 KiB and a larger one is refused for that image. --- crates/create/src/extract.rs | 7 ++++++- crates/create/src/tests.rs | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 768da4d1f1..b1aa5e5b11 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -359,6 +359,9 @@ impl Space { } } +/// A lookup table holds at most 256 entries of 4 bytes; decoding stops well past that. +const MAX_LOOKUP: usize = 64 << 10; + fn space(doc: &Document, cs: Option<&Object>) -> Result { let Some(cs) = cs else { return Err("the image has no colour space".into()) }; let cs = doc.resolve(cs); @@ -393,7 +396,9 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result { }; let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() { Some(Object::String(s)) => s.bytes.clone(), - Some(Object::Stream(t)) => t.decoded().map_err(|e| e.to_string())?, + Some(Object::Stream(t)) => { + t.decoded_within(MAX_LOOKUP).map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())? + } _ => return Err("an indexed colour space without a lookup table".into()), }; Ok(Space::Indexed(Box::new(base), hival, table)) diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 6356105699..131cb10d2c 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -832,6 +832,15 @@ fn indexed_hival_resolves_indirect_values_and_stream_palettes() { } } +#[test] +fn indexed_lookup_stream_has_a_size_cap() { + // A lookup table needs 256 × 4 bytes at most; a 1 MiB one is refused, not decoded whole. + let mut palette = vec![255, 0, 0, 0, 0, 255]; + palette.resize(1 << 20, 0); + let (doc, image) = indexed_export_fixture(Object::Int(1), palette, true, false, 8, vec![0, 1, 0, 1]); + assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("lookup table")); +} + #[test] fn indexed_hival_boundary_values_keep_valid_palette_entries() { let palette: Vec = (0..=255u8).flat_map(|i| [i, 255 - i, 17]).collect(); From 05df6ee4b722943a7b28ccadcb9bc9950d82480d Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:10:15 +0000 Subject: [PATCH 104/517] fix(fonts): decode each candidate font program once, within its size Looking for an already embedded full program decoded every candidate up to 17 MiB, once per resource naming it, so a page whose PCFull resources all alias one compressed stream, or many marked streams, made one text edit inflate gigabytes. Candidates now decode within the program's own length, each once per embedding, and at most 16 in all; the resource check compares its CMap streams within their expected lengths too. --- crates/fonts/src/embedding.rs | 140 ++++++++++++++++++++++++++-------- 1 file changed, 108 insertions(+), 32 deletions(-) diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index a5bb011e00..23f34dbb36 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -1,9 +1,10 @@ //! Unicode embedding of an explicitly supplied, embeddable TrueType face. Font files are //! document inputs or the optional craft-fonts build input, never system-interface fallbacks. +use std::collections::HashMap; use std::fmt::Write as _; -use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use skrifa::{ FontRef, MetadataProvider, instance::{LocationRef, Size}, @@ -173,7 +174,8 @@ pub fn embed_mapping( return Err("the font family name is invalid".into()); } let glyph_map = if cff { cid_encoding.as_bytes() } else { gids.as_slice() }; - if let Some(resource) = same_resource(doc, fonts, &name, bytes, glyph_map, cmap.as_bytes()) { + let mut programs = ProgramMatch::new(bytes); + if let Some(resource) = same_resource(doc, fonts, &name, &mut programs, glyph_map, cmap.as_bytes()) { return Ok(resource); } let mut descriptor = Dict::new(); @@ -193,12 +195,7 @@ pub fn embed_mapping( ); // Reuse the program another resource of this page already has, else one registered on another // page; source subset programs stay untouched. - let existing = written(doc, fonts).find(|w| w.has_program(doc, bytes)).map(|w| w.program).or_else(|| { - doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() { - Object::Stream(s) if is_program(doc, s, bytes) => Some(Object::Ref(r)), - _ => None, - }) - }); + let existing = existing_program(doc, fonts, &mut programs); let program = existing.unwrap_or_else(|| { let mut d = Dict::new(); d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64)); @@ -246,12 +243,64 @@ pub fn embed_mapping( Ok(resource) } -/// The decoded data of the stream `object` refers to. -fn stream_data(doc: &Document, object: Option<&Object>) -> Option> { - match doc.resolve(object?).as_ref() { - Object::Stream(s) => s.decoded_within(17 << 20).ok(), - _ => None, +/// Whether the stream `object` refers to decodes to `expected`. +fn stream_is(doc: &Document, object: Option<&Object>, expected: &[u8]) -> bool { + object.is_some_and(|o| match doc.resolve(o).as_ref() { + Object::Stream(s) => s.decoded_within(expected.len()).is_ok_and(|d| d == expected), + _ => false, + }) +} + +/// Full programs one embedding decodes at most while looking for its own. A real match passes +/// the cheap checks and decodes once; many candidates that pass them come from a hostile file. +const MAX_PROGRAM_DECODES: usize = 16; + +/// Recognises the full program written for `bytes`: each candidate stream decodes at most once +/// and within `bytes.len()`, and at most [`MAX_PROGRAM_DECODES`] decode in all. +struct ProgramMatch<'a> { + bytes: &'a [u8], + known: HashMap, + decodes: usize, +} + +impl<'a> ProgramMatch<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, known: HashMap::new(), decodes: 0 } } + + /// Whether `r`, holding `object`, is the program. + fn is(&mut self, doc: &Document, r: ObjRef, object: &Object) -> bool { + if let Some(&known) = self.known.get(&r) { + return known; + } + let found = match object { + Object::Stream(s) + if s.dict.get(b"PCFullFont").is_some() + && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(self.bytes.len()).ok() + && self.decodes < MAX_PROGRAM_DECODES => + { + self.decodes = self.decodes.saturating_add(1); + s.decoded_within(self.bytes.len()).is_ok_and(|data| data == self.bytes) + } + _ => false, + }; + self.known.insert(r, found); + found + } + + /// Whether the resource's program is this one. + fn written(&mut self, doc: &Document, w: &Written) -> bool { + w.program.as_ref().is_some_and(|r| self.is(doc, r, &*doc.get(r))) + } +} + +/// The program for `programs.bytes` that this page's `PCFull` resources, or else the first +/// objects of the document, already have. +fn existing_program(doc: &Document, fonts: &Dict, programs: &mut ProgramMatch) -> Option { + written(doc, fonts) + .find(|w| programs.written(doc, w)) + .map(|w| w.program) + .or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &*o).then_some(Object::Ref(r)))) } /// A `PCFull` resource that [`embed_mapping`] wrote. @@ -263,21 +312,6 @@ struct Written { program: Object, } -impl Written { - /// Whether its program is `bytes`. - fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool { - matches!(doc.resolve(&self.program).as_ref(), Object::Stream(s) if is_program(doc, s, bytes)) - } -} - -/// Whether `s` is a full program written for `bytes`, compressed or not. The length it was -/// written with is compared before decoding. -fn is_program(doc: &Document, s: &Stream, bytes: &[u8]) -> bool { - s.dict.get(b"PCFullFont").is_some() - && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok() - && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes) -} - /// The `PCFull` resources in `fonts`, in order. fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator + 'a { fonts.iter().filter(|(key, _)| key.starts_with(b"PCFull")).take(256).filter_map(move |(key, value)| { @@ -291,14 +325,14 @@ fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator Option { +fn same_resource(doc: &Document, fonts: &Dict, name: &str, programs: &mut ProgramMatch, glyph_map: &[u8], unicode: &[u8]) -> Option { written(doc, fonts) .find(|w| { let glyphs = w.cid.get(b"CIDToGIDMap").or_else(|| w.top.get(b"Encoding")); w.top.name(b"BaseFont") == Some(name.as_bytes()) - && stream_data(doc, w.top.get(b"ToUnicode")).is_some_and(|d| d == unicode) - && stream_data(doc, glyphs).is_some_and(|d| d == glyph_map) - && w.has_program(doc, bytes) + && stream_is(doc, w.top.get(b"ToUnicode"), unicode) + && stream_is(doc, glyphs, glyph_map) + && programs.written(doc, w) }) .map(|w| w.resource) } @@ -385,6 +419,48 @@ mod tests { assert_eq!(programs(&doc), 2); } + #[test] + fn the_program_search_decodes_each_candidate_once_and_few_in_all() { + let bytes = b"the program".as_slice(); + let decoy = |doc: &mut Document| { + let mut d = Dict::new(); + d.set(b"PCFullFont".to_vec(), Object::Bool(true)); + d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64)); + doc.add(Object::Stream(Stream::flate(d, &[0; 4096]))) + }; + let resource = |doc: &mut Document, program: ObjRef| { + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut cid = Dict::new(); + cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + let mut top = Dict::new(); + top.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid)])); + doc.add(Object::Dict(top)) + }; + // 200 resources sharing one decoy program decode it once. + let mut doc = Document::new_empty(); + let shared = decoy(&mut doc); + let mut fonts = Dict::new(); + for i in 0..200 { + let r = resource(&mut doc, shared); + fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r)); + } + let mut programs = ProgramMatch::new(bytes); + assert_eq!(existing_program(&doc, &fonts, &mut programs), None); + assert_eq!(programs.decodes, 1); + // 100 distinct decoys decode a bounded number of times. + let mut doc = Document::new_empty(); + let mut fonts = Dict::new(); + for i in 0..100 { + let program = decoy(&mut doc); + let r = resource(&mut doc, program); + fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r)); + } + let mut programs = ProgramMatch::new(bytes); + assert_eq!(existing_program(&doc, &fonts, &mut programs), None); + assert_eq!(programs.decodes, MAX_PROGRAM_DECODES); + } + #[test] fn the_full_program_is_written_compressed_and_another_page_reuses_it() { let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); From 8da1164f7d303487d3c90861bc2994e9ab89f5e0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:10:15 +0000 Subject: [PATCH 105/517] fix(fonts): decode Encoding and ToUnicode CMaps within 16 MiB Both CMap streams decoded under the general 1 GiB stream limit, so a small Flate stream of whitespace inflated to a gigabyte that was then lexed on every font load. They now decode within 16 MiB, like the font programs beside them, and a larger CMap is not read. --- crates/fonts/src/pdf.rs | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0aaed6939e..fb524f65ee 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -275,6 +275,9 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20; /// Most codespace or CID ranges kept from a CMap; Adobe's largest published CMaps have a few /// thousand. const MAX_CMAP_RANGES: usize = 1 << 16; +/// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger +/// one is not decoded at all. +const MAX_CMAP_BYTES: usize = 16 << 20; /// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range /// costs one record however many codes it covers; where two overlap, the one declared later wins. @@ -679,7 +682,7 @@ impl Metrics { m.codes = Codes::Two; if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e)) && let Object::Stream(s) = &*e - && let Ok(data) = s.decoded() + && let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) { let (spaces, cids) = parse_cmap(&data); if !spaces.is_empty() { @@ -1220,7 +1223,7 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { } match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() { Some(Object::Stream(s)) => { - if let Ok(data) = s.decoded() { + if let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) { parse_to_unicode(&data, &mut out); } } @@ -1496,6 +1499,38 @@ mod tests { assert_eq!(m.encode("abc"), None); } + #[test] + fn cmap_streams_decode_within_a_size_cap() { + // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole. + let to_unicode = b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange"; + let encoding = b"1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange"; + for padded in [false, true] { + let mut doc = Document::new_empty(); + let mut stream = |cmap: &[u8]| { + let mut data = cmap.to_vec(); + if padded { + data.resize(MAX_CMAP_BYTES + 1, b' '); + } + Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &data)))) + }; + let (tu, enc) = (stream(to_unicode), stream(encoding)); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", enc), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", tu), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, 0x24]) == "A", !padded, "ToUnicode, padded: {padded}"); + assert_eq!(matches!(m.codes, Codes::Ranges(_)), !padded, "Encoding, padded: {padded}"); + } + } + #[test] fn composite_fonts_use_to_unicode() { let mut doc = Document::new_empty(); From a16661ac102e921524459bc991abb4473115ab2f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:11:27 +0000 Subject: [PATCH 106/517] fix(measure): list a repeated measurement once and cap what a listing keeps The listing read a measurement for every /Annots entry, so one annotation referenced many times, or many small annotations sharing one indirect 2048-point vertex array, multiplied its points and text in memory. The listing skips annotation references it has already read and stops, marked truncated, once the kept points and text pass 32 MiB. --- crates/measure/src/lib.rs | 29 +++++++++++++++++++++++++++-- crates/measure/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index 72b6c4b07b..4843d0fb68 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -448,10 +448,20 @@ pub struct Unsupported { pub struct Listing { pub measurements: Vec, pub unsupported: Vec, - /// The annotation limit stopped enumeration early. + /// The annotation or retained-size limit stopped enumeration early. pub truncated: bool, } const MAX_ANNOTATIONS: usize = 100_000; +/// Points and text one listing keeps, as counted by [`retained`]: about a thousand +/// measurements of [`MAX_POINTS`] points each. +const MAX_LISTED_BYTES: usize = 32 << 20; +/// The bytes a listed measurement keeps in points and text. +fn retained(m: &Measurement) -> usize { + let s = &m.scale; + [m.id.len(), m.author.len(), m.label.len(), s.unit.len(), s.area_unit.len(), s.ratio.len()] + .into_iter() + .fold(m.points.len().saturating_mul(std::mem::size_of::()), usize::saturating_add) +} fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: usize) -> Result { let scale = Scale::read(doc, d.get(b"Measure").ok_or_else(|| invalid("measurement has no scale"))?)?; let key = if kind == Kind::Distance { &b"L"[..] } else { b"Vertices" }; @@ -471,6 +481,8 @@ fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: us pub fn list(doc: &Document) -> Listing { let mut out = Listing::default(); let mut count = 0usize; + let mut kept = 0usize; + let mut seen = std::collections::HashSet::new(); for (page, p) in pdfcraft_model::pages(doc).iter().enumerate() { let Some(o) = p.dict.get(b"Annots") else { continue }; let o = doc.resolve(o); @@ -481,6 +493,12 @@ pub fn list(doc: &Document) -> Listing { out.truncated = true; return out; } + // One annotation object under several entries or pages is one measurement. + if let Some(r) = a.as_ref() + && !seen.insert(r) + { + continue; + } let o = doc.resolve(a); let Some(d) = o.as_dict() else { continue }; let kind = match d.name(b"IT") { @@ -490,7 +508,14 @@ pub fn list(doc: &Document) -> Listing { _ => continue, }; match read_measurement(doc, d, kind, page, index) { - Ok(m) => out.measurements.push(m), + Ok(m) => { + kept = kept.saturating_add(retained(&m)); + if kept > MAX_LISTED_BYTES { + out.truncated = true; + return out; + } + out.measurements.push(m); + } Err(e) => out.unsupported.push(Unsupported { page, index, reason: e.to_string() }), } } diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 303f9b4386..315fedc937 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -403,3 +403,23 @@ fn a_scale_ratio_converts_its_page_side_to_points() { assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}"); } } +#[test] +fn listing_skips_repeated_refs_and_caps_what_it_keeps() { + let mut d = fixture("", "", &[]); + let points: Vec = (0..MAX_POINTS).map(|i| [i as f64 * 0.1, 0.0]).collect(); + add(&mut d, &new(Kind::Perimeter, points), &Meta::default()).unwrap(); + let (r, mut annotation) = annot(&d, 0, 0).unwrap(); + let page = pdfcraft_model::pages(&d)[0].obj; + let set_annots = |d: &mut Document, list: Vec| d.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(list))).unwrap(); + // One annotation referenced 1000 times is one measurement. + set_annots(&mut d, vec![Object::Ref(r); 1000]); + assert_eq!(list(&d).measurements.len(), 1); + // Many small annotations sharing one 2048-point vertex array stop at the retained budget. + let vertices = d.add(annotation.get(b"Vertices").unwrap().clone()); + annotation.set(b"Vertices".to_vec(), Object::Ref(vertices)); + set_annots(&mut d, vec![Object::Dict(annotation); 1200]); + let listing = list(&d); + assert!(listing.truncated); + assert!(listing.measurements.iter().map(retained).sum::() <= MAX_LISTED_BYTES); + assert!(!listing.measurements.is_empty()); +} From a34697a8eb8d67088543effce1411a85cb0604ed Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:12:31 +0000 Subject: [PATCH 107/517] fix(measure): a failed content decode uses up the snap byte budget Snap geometry charged decoded bytes only on success, so a /Contents array repeating one stream that inflates past the 16 MiB limit ran that inflation for every entry without spending any budget. A tolerant decode fails only on an unsupported filter or after producing the remaining budget, so a failure now spends the rest of the budget and later parts are skipped as truncated. --- crates/measure/src/snap.rs | 12 +++++++++--- crates/measure/src/tests.rs | 13 +++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index 3cf6bbd17e..37e64e8b8a 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -214,14 +214,14 @@ impl Walker<'_> { self.geometry.truncated = true; break; } - // An undecodable part is skipped; the rest of the page still snaps. + // An undecodable part is skipped; the parts before it still snap. match s.decoded_within(remaining) { Ok(data) => { joined.extend(data); // A space, as the renderer joins pieces: a trailing comment runs on. joined.push(b' '); } - Err(_) => self.geometry.unreadable = self.geometry.unreadable.saturating_add(1), + Err(_) => self.decode_failed(remaining), } } } @@ -236,11 +236,17 @@ impl Walker<'_> { match stream.decoded_within(remaining) { Ok(data) => self.ops(&data, resources, initial, depth), Err(_) => { - self.geometry.unreadable = self.geometry.unreadable.saturating_add(1); + self.decode_failed(remaining); Ok(()) } } } + /// A tolerant decode fails on an unsupported filter or once it has produced `remaining` bytes, + /// so a failure uses up the byte budget: repeating a failing stream cannot repeat that work. + fn decode_failed(&mut self, remaining: usize) { + self.geometry.unreadable = self.geometry.unreadable.saturating_add(1); + self.bytes = self.bytes.saturating_add(remaining); + } fn ops(&mut self, data: &[u8], resources: &Dict, initial: Matrix, depth: usize) -> Result<()> { self.bytes = self.bytes.saturating_add(data.len()); self.streams = self.streams.saturating_add(1); diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 315fedc937..e9722cb622 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -423,3 +423,16 @@ fn listing_skips_repeated_refs_and_caps_what_it_keeps() { assert!(listing.measurements.iter().map(retained).sum::() <= MAX_LISTED_BYTES); assert!(!listing.measurements.is_empty()); } +#[test] +fn a_stream_over_the_byte_budget_uses_it_up_once() { + // 50 references to one Flate stream that inflates past the 16 MiB budget decode once. + let big = pdfcraft_cos::Stream::flate(Dict::new(), &vec![b' '; 17 << 20]); + let hex: String = big.raw.iter().map(|b| format!("{b:02x}")).collect(); + let bomb = String::from_utf8(pdfcraft_testkit::stream_obj("/Filter [/ASCIIHexDecode /FlateDecode]", hex)).unwrap(); + let contents = format!("[4 0 R{}]", " 5 0 R".repeat(50)); + let d = fixture_with(&contents, "0 0 m 10 0 l S", "", &[bomb.as_str()]); + let g = snap::geometry(&d, 0).unwrap(); + assert_eq!(g.unreadable, 1); + assert!(g.truncated); + assert_eq!(g.segments.len(), 1, "the parts before it still snap"); +} From 853a9ae1e33f9c906df7084d20a12eaf5c92ebe4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:13:06 +0000 Subject: [PATCH 108/517] fix(preflight): decode an output-intent profile within 16 MiB Reading the colour space from an ICC header when /N is missing decoded the whole profile under the general 1 GiB stream limit to look at four bytes. The profile now decodes within 16 MiB; a larger one leaves the component count unknown. --- crates/preflight/src/lib.rs | 5 ++++- crates/preflight/src/tests.rs | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 2cda29fea1..ce17a4a8df 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -214,6 +214,9 @@ fn has_pdfa_intent(doc: &Document) -> bool { pdfa_intent_profile(doc).is_some() } +/// Largest output-intent profile decoded to read its header; real ICC profiles stay well under it. +const MAX_ICC_PROFILE: usize = 16 << 20; + /// The PDF/A output intent's destination profile: `Some(n)` with the profile's number of colour /// components (`/N`, else the ICC header's data colour space; `None` when neither says), or /// `None` when the document has no PDF/A output intent. @@ -229,7 +232,7 @@ fn pdfa_intent_profile(doc: &Document) -> Option> { let Object::Stream(s) = &*profile else { return Some(None) }; Some(s.dict.int(b"N").or_else(|| { // ICC.1 header: the data colour space is bytes 16–19. - match s.decoded().ok()?.get(16..20)? { + match s.decoded_within(MAX_ICC_PROFILE).ok()?.get(16..20)? { b"GRAY" => Some(1), b"RGB " => Some(3), b"CMYK" => Some(4), diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 80b1c63c49..efd55f28e3 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -417,3 +417,15 @@ fn oversized_xmp_is_refused_while_decoding() { assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress"); assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding"); } + +#[test] +fn an_output_intent_profile_header_is_read_within_a_size_cap() { + let intent = "/OutputIntents [<< /S /GTS_PDFA1 /DestOutputProfile 2 0 R >>]"; + for (size, n) in [(128, Some(4)), (MAX_ICC_PROFILE + 1, None)] { + let mut doc = build(&[&format!("<< /Type /Catalog {intent} >>"), "null"], ""); + let mut profile = vec![0; size]; + profile[16..20].copy_from_slice(b"CMYK"); + doc.set(pdfcraft_cos::ObjRef::new(2, 0), Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &profile))); + assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); + } +} From dfde90787f416f8d4953f861f308a3407f075839 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:14:55 +0000 Subject: [PATCH 109/517] fix(render): list a repeated annotation once and cap what the listing keeps Inspection built an annotation record, with owned copies of its text, for every /Annots entry on every page, so one comment referenced many times, a page-shared /Annots array, or many small comments sharing one long /Contents string multiplied that text in memory. Pages are read in order, an annotation or /Annots array already listed is skipped, and the listing stops once the kept text and geometry pass 64 MiB. --- crates/render/src/inspect.rs | 75 ++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index e9afb4ec47..6c3bd2c0de 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -38,6 +38,24 @@ const MAX_XFA_CONFIG_BYTES: usize = 16 << 20; const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096; const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024; +/// Text and geometry `DocInfo::annotations` keeps, as counted by [`retained`]. Past it the rest +/// are not listed: real comments stay far below, many small ones sharing one long string do not. +const MAX_ANNOTATION_BYTES: usize = 64 << 20; + +/// The bytes a listed annotation keeps. +fn retained(a: &Annotation) -> usize { + let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent]; + text.into_iter() + .map(|t| t.as_ref().map_or(0, String::len)) + .chain([ + a.subtype.len(), + std::mem::size_of_val(a.quads.as_slice()), + std::mem::size_of_val(a.points.as_slice()), + std::mem::size_of::(), + ]) + .fold(0, usize::saturating_add) +} + /// At most this many layers are read from all of `/RBGroups` together. const MAX_LAYER_GROUP_ENTRIES: usize = 4096; @@ -1142,10 +1160,26 @@ impl<'a> Inspector<'a> { // ── annotations ───────────────────────────────────────────────────────────────────────── fn annotations(&self, info: &mut DocInfo) { - for (&id, &page) in &self.page_index { + let mut pages: Vec<(ObjectId, usize)> = self.page_index.iter().map(|(&id, &page)| (id, page)).collect(); + pages.sort_unstable_by_key(|&(_, page)| page); + // An annotation, or a whole `/Annots` array, that several entries or pages list is on the + // first of them (§12.5.2 allows one page per annotation). + let mut seen = HashSet::new(); + let mut kept = 0usize; + for (id, page) in pages { let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; + if let Ok(Object::Reference(r)) = page_dict.get(b"Annots") + && !seen.insert(*r) + { + continue; + } let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; for (index, a) in annots.iter().enumerate() { + if let Object::Reference(r) = a + && !seen.insert(*r) + { + continue; + } let Some(d) = self.dict(a) else { continue }; let Some(subtype) = self.name(d, b"Subtype") else { continue }; if subtype == "FileAttachment" @@ -1209,7 +1243,7 @@ impl<'a> Inspector<'a> { } _ => Vec::new(), }; - info.annotations.push(Annotation { + let annotation = Annotation { page, subtype, author: self.text(d, b"T"), @@ -1228,7 +1262,15 @@ impl<'a> Inspector<'a> { intent: self.name(d, b"IT"), fill_sign, points, - }); + }; + kept = kept.saturating_add(retained(&annotation)); + if kept > MAX_ANNOTATION_BYTES { + break; + } + info.annotations.push(annotation); + } + if kept > MAX_ANNOTATION_BYTES { + break; } } info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); @@ -2583,6 +2625,33 @@ trailer << /Root 1 0 R >> } } + /// One comment referenced many times, or a page-shared `/Annots` array, is listed once, and many + /// small comments sharing one large `/Contents` string stop at the retained-bytes budget. + #[test] + fn repeated_annotations_are_listed_once_within_a_byte_budget() { + let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 7 0 R >> ".repeat(1200); + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ + 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ + 5 0 obj [{} {small}] endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (note) >> endobj\n\ + 7 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + "6 0 R ".repeat(1000), + "x".repeat(64 << 10), + ); + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; + Inspector::new(&doc).fill(&mut info); + let notes = &info.annotations; + assert_eq!(notes.iter().filter(|a| a.contents.as_deref() == Some("note")).count(), 1); + assert!(notes.iter().all(|a| a.page == 0), "the shared array is the first page's"); + assert!(notes.len() > 1); + assert!(notes.iter().map(retained).sum::() <= MAX_ANNOTATION_BYTES); + } + /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. From 77ee1c8a26c2efadc82952888ffd69ccba36e6de Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:16:19 +0000 Subject: [PATCH 110/517] fix(sign): read DSS revocation evidence one stream at a time within caps Revocation checking decoded every /CRLs and /OCSPs entry under the general 1 GiB stream limit and kept them all before parsing any, so one compressed stream referenced many times multiplied decoding and memory. Evidence now decodes lazily, each stream once and within the 2 MiB evidence cap, with at most 1024 entries and 64 MiB per array. --- crates/sign/src/pdf.rs | 47 ++++++++++++++++++-------- crates/sign/src/pdf_discovery_tests.rs | 22 ++++++++++++ crates/sign/src/revocation.rs | 2 +- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 82a801dee8..04598e0333 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -975,6 +975,37 @@ fn finish_validation( } } +/// Evidence streams read from one `/DSS` array. +const MAX_DSS_ENTRIES: usize = 1024; +/// Decoded bytes read from one `/DSS` array; real CRLs and OCSP responses are kilobytes each. +const MAX_DSS_BYTES: usize = 64 << 20; + +/// The decoded streams of the `/DSS` evidence array `key` (`/CRLs`, `/OCSPs`), one at a time so +/// each is checked before the next decodes: each stream once and within the evidence size cap, +/// at most [`MAX_DSS_ENTRIES`] of them and [`MAX_DSS_BYTES`] together. +fn dss_evidence<'a>(doc: &'a Document, dss: &Dict, key: &[u8]) -> impl Iterator> + 'a { + let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let mut items = items.into_iter().take(MAX_DSS_ENTRIES); + let mut seen = HashSet::new(); + let mut left = MAX_DSS_BYTES; + std::iter::from_fn(move || { + for item in items.by_ref() { + if left == 0 { + return None; + } + if item.as_ref().is_some_and(|r| !seen.insert(r)) { + continue; + } + let o = doc.resolve(&item); + let Object::Stream(s) = o.as_ref() else { continue }; + let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE.min(left)) else { continue }; + left = left.saturating_sub(bytes.len()); + return Some(bytes); + } + None + }) +} + /// Check the revocation evidence in the catalog's `/DSS` against the signer's chain at `at`. /// Only evidence whose signature verifies against the issuer counts; returns whether the /// certificate is revoked (which invalidates the signature). @@ -985,25 +1016,13 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio let Some(dss) = doc.get(root).as_dict().and_then(|c| c.get(b"DSS").cloned()).map(|d| doc.resolve(&d)).and_then(|d| d.as_dict().cloned()) else { return false; }; - let pull = |key: &[u8]| -> Vec> { - let mut out = Vec::new(); - for item in dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { - let o = doc.resolve(&item); - if let pdfcraft_cos::Object::Stream(s) = &*o - && let Ok(bytes) = s.decoded() - { - out.push(bytes); - } - } - out - }; let issuer = info.chain.get(1).unwrap_or(signer); // A verified revocation wins; "good" from any covering evidence is reported, but never // contradicts a revocation. let mut revoked = None; let mut covered = false; let mut sources = Vec::new(); - for r in pull(b"CRLs") { + for r in dss_evidence(doc, &dss, b"CRLs") { let Ok(list) = CertificateList::parse(&r) else { continue }; sources.push("CRL"); match list.check(signer, issuer, at) { @@ -1016,7 +1035,7 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio } } if revoked.is_none() { - for r in pull(b"OCSPs") { + for r in dss_evidence(doc, &dss, b"OCSPs") { let Ok(resp) = OcspResponse::parse(&r) else { continue }; sources.push("OCSP"); match resp.check(signer, issuer, at) { diff --git a/crates/sign/src/pdf_discovery_tests.rs b/crates/sign/src/pdf_discovery_tests.rs index 66bf7c8a0e..9bc4448238 100644 --- a/crates/sign/src/pdf_discovery_tests.rs +++ b/crates/sign/src/pdf_discovery_tests.rs @@ -371,3 +371,25 @@ fn only_grew_compares_huge_arrays_without_quadratic_work() { assert!(!only_grew(&doc, &doc, &ints(20_000), &ints(20_001), 0)); assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); } + +#[test] +fn dss_evidence_decodes_each_stream_once_within_its_caps() { + // One small stream listed 1000 times, then streams that inflate past the evidence cap. + let mut doc = fixture(&[]); + let small = doc.add(Object::Stream(Stream::flate(Dict::new(), b"evidence"))); + let mut items = vec![Object::Ref(small); 1000]; + for _ in 0..4 { + items.push(Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![0; crate::revocation::MAX_EVIDENCE + 1]))))); + } + let mut dss = Dict::new(); + dss.set(b"CRLs".to_vec(), Object::Array(items)); + let read: Vec> = dss_evidence(&doc, &dss, b"CRLs").collect(); + assert_eq!(read, vec![b"evidence".to_vec()]); + // Distinct streams stop at the entry and byte budgets. + let items: Vec = + (0..MAX_DSS_ENTRIES + 10).map(|_| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &[1; 1 << 16]))))).collect(); + dss.set(b"OCSPs".to_vec(), Object::Array(items)); + let total: usize = dss_evidence(&doc, &dss, b"OCSPs").map(|b| b.len()).sum(); + assert!(total <= MAX_DSS_BYTES, "{total}"); + assert!(dss_evidence(&doc, &dss, b"OCSPs").count() <= MAX_DSS_ENTRIES); +} diff --git a/crates/sign/src/revocation.rs b/crates/sign/src/revocation.rs index b4a9d820f9..057e0717de 100644 --- a/crates/sign/src/revocation.rs +++ b/crates/sign/src/revocation.rs @@ -8,7 +8,7 @@ use crate::x509::Certificate; use crate::{SignError, Time}; /// A DER size cap: real CRLs and OCSP responses are far smaller. -const MAX_EVIDENCE: usize = 2 * 1024 * 1024; +pub(crate) const MAX_EVIDENCE: usize = 2 * 1024 * 1024; const ID_PKIX_OCSP_BASIC: &str = "1.3.6.1.5.5.7.48.1.1"; const EKU_OCSP_SIGNING: &str = "1.3.6.1.5.5.7.3.9"; From 2e1c029fa82efeaae0b9c339a6b1ad3ea6d90f08 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:18:26 +0000 Subject: [PATCH 111/517] fix(xfa): every flowed subform instance spends the layout budget Nested repeating subforms multiplied their instances (up to 50 per level over 64 levels), and an empty instance neither measured nor placed anything, so it spent none of the measurement or item budgets. Each instance now counts as a measurement, and layout stops with TooLarge once the budget is spent. --- crates/xfa/src/layout.rs | 3 +++ crates/xfa/src/tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 326e7f7413..b4d3312559 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -769,6 +769,9 @@ impl Layouter<'_> { self.warn_truncated(node, &ctx.som, instances); let ctx_parent = ctx; for i in 0..instances { + // Every instance spends budget, so nested repeats that place nothing still stop. + self.measures.set(self.measures.get().saturating_add(1)); + self.check_measures()?; let ctx = ctx_parent.with_som(&sf.common, sib + i); let (rect, _, _) = self.cursor(); let (x, w) = avail.unwrap_or((rect.x, rect.w)); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 84c9fc00f8..c5de7ad887 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -404,6 +404,18 @@ fn repeated_pictures_are_bounded_in_total_bytes() { assert!(layout_xml(&ok).is_ok(), "one small picture must lay out"); } +#[test] +fn empty_repeating_subforms_spend_the_layout_budget() { + // Four nested levels of 50 empty instances are 6.25 million instances that place nothing + // and measure nothing; each one counts against the layout budget. + let level = ""; + let tpl = format!("", level.repeat(4), "".repeat(4)); + assert!(matches!(layout_xml(&tpl), Err(XfaError::TooLarge(_)))); + // Three levels (125,000 instances) still lay out. + let tpl = format!("", level.repeat(3), "".repeat(3)); + assert!(layout_xml(&tpl).is_ok()); +} + #[test] fn the_packets_can_be_one_stream_or_named_parts() { // Named parts, with the template split from the rest. From 306627ee778ca04498dc4a14f467bf6def134ddc Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:19:08 +0000 Subject: [PATCH 112/517] fix(xfa): read a packet stream the XFA array lists again only once The packet reader decoded the stream of every /XFA array entry and charged only the decoded output to its 64 MB budget, so one stream that decodes to almost nothing (Flate over hex whitespace), listed many times, repeated a large decode for free. A stream reference already read is skipped. --- crates/xfa/src/packets.rs | 6 ++++++ crates/xfa/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/crates/xfa/src/packets.rs b/crates/xfa/src/packets.rs index 875cdc894e..f79b071c0e 100644 --- a/crates/xfa/src/packets.rs +++ b/crates/xfa/src/packets.rs @@ -113,7 +113,13 @@ pub fn read_packets(doc: &Document) -> Result, XfaError> { let needs_rendering = bool_of(catalog.as_dict().and_then(|c| c.get(b"NeedsRendering"))).or_else(|| bool_of(acro.get(b"NeedsRendering"))); let has_fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().map(|a| !a.is_empty())).unwrap_or(false); let mut total = 0usize; + // A stream listed again adds nothing but another decode, which may cost far more than the + // bytes it yields (a Flate stream of hex whitespace). + let mut seen = std::collections::HashSet::new(); let mut read = |o: &Object| -> Result, XfaError> { + if o.as_ref().is_some_and(|r| !seen.insert(r)) { + return Ok(None); + } let s = doc.resolve(o); let Object::Stream(s) = &*s else { return Ok(None) }; let bytes = s.decoded_within(MAX_XDP)?; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index c5de7ad887..03a7d2c5aa 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -416,6 +416,23 @@ fn empty_repeating_subforms_spend_the_layout_budget() { assert!(layout_xml(&tpl).is_ok()); } +#[test] +fn a_packet_stream_listed_many_times_is_read_once() { + let mut doc = Document::open(Arc::new(shell(""))).unwrap(); + let packet = pdfcraft_cos::ObjRef::new(5, 0); + let items: Vec = + (0..1000).flat_map(|_| [Object::String(pdfcraft_cos::PdfString::literal(b"template".to_vec())), Object::Ref(packet)]).collect(); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + let mut acro = c.get(b"AcroForm").and_then(Object::as_dict).cloned().unwrap(); + acro.set(b"XFA".to_vec(), Object::Array(items)); + c.set(b"AcroForm".to_vec(), Object::Dict(acro)); + }) + .unwrap(); + let p = read_packets(&doc).unwrap().unwrap(); + assert_eq!(p.xdp.matches(" Date: Thu, 22 Oct 2026 17:12:57 +0000 Subject: [PATCH 113/517] fix(fonts): let auto-deref pass the candidate objects to the program match Clippy's explicit_auto_deref rejects the explicit derefs. --- crates/fonts/src/embedding.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index 23f34dbb36..2b7b4e17e1 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -290,7 +290,7 @@ impl<'a> ProgramMatch<'a> { /// Whether the resource's program is this one. fn written(&mut self, doc: &Document, w: &Written) -> bool { - w.program.as_ref().is_some_and(|r| self.is(doc, r, &*doc.get(r))) + w.program.as_ref().is_some_and(|r| self.is(doc, r, &doc.get(r))) } } @@ -300,7 +300,7 @@ fn existing_program(doc: &Document, fonts: &Dict, programs: &mut ProgramMatch) - written(doc, fonts) .find(|w| programs.written(doc, w)) .map(|w| w.program) - .or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &*o).then_some(Object::Ref(r)))) + .or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &o).then_some(Object::Ref(r)))) } /// A `PCFull` resource that [`embed_mapping`] wrote. From db0689e381c8e7aaa1c013e81d47499d6ec3ba8b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:18:09 +0200 Subject: [PATCH 114/517] Save keeps the open text box or paragraph the document refused, and still applies the other (#3496) --- crates/ui-egui/src/edit_text_ui.rs | 33 ++++++++++++++++++++++++++++++ crates/ui-egui/src/editing.rs | 15 +++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index f9a028a0ef..57a17f5a0a 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -1513,6 +1513,39 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn a_refused_text_box_does_not_lose_the_open_paragraph() { + let (mut app, mut ed) = preview_app(); + ed.text = "Text preview".into(); + app.views[0].line_editor = Some(ed); + let draft = crate::content_ui::TextDraft { + page: 9, + index: None, + rect: [20.0, 20.0, 120.0, 40.0], + text: "Typed text".into(), + style: crate::content_ui::default_style(), + focus: true, + }; + app.views[0].content.draft = Some(draft.clone()); + + assert!(!app.commit_typing_in(app.views[0].id)); + assert_eq!(app.views[0].content.draft, Some(draft), "the refused text box stays open"); + assert!(app.views[0].line_editor.is_none(), "the paragraph was applied"); + let doc = app.session.get(app.views[0].id).unwrap(); + assert_eq!(doc.text_lines(0).iter().map(|l| l.text.as_str()).collect::(), "Text preview"); + } + + #[test] + fn a_refused_paragraph_stays_open_with_its_text() { + let (mut app, mut ed) = preview_app(); + ed.text = "Text preview".into(); + ed.page = 9; + app.views[0].line_editor = Some(ed.clone()); + + assert!(!app.commit_typing_in(app.views[0].id)); + assert_eq!(app.views[0].line_editor, Some(ed), "the paragraph keeps its unsaved text"); + } + #[test] fn text_background_is_cached_without_editing_and_released_on_cancel() { let (app, ed) = preview_app(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 6220dc0645..992eb63a38 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -545,9 +545,22 @@ impl PdfCraftApp { fn commit_open_text_edits(&mut self, id: pdfcraft_engine::DocId) -> bool { let Some(doc) = self.session.get(id) else { return true }; let Some(view) = self.views.iter_mut().find(|v| v.id == id) else { return true }; + // Both are taken out of the view before either edit runs, so keep what is needed to put + // back whichever one the document refuses: typed text must not be lost. + let (draft, select_added, editor) = (view.content.draft.clone(), view.content.select_added, view.line_editor.clone()); let added = view.content.finish_draft(doc.added()); let paragraph = view.line_editor.take_if(|ed| ed.has_changes()).and_then(crate::edit_text_ui::finished_edit); - added.into_iter().chain(paragraph).all(|edit| self.apply_edit(edit)) + let added_ok = added.is_none_or(|edit| self.apply_edit(edit)); + let paragraph_ok = paragraph.is_none_or(|edit| self.apply_edit(edit)); + if let Some(view) = self.views.iter_mut().find(|v| v.id == id) { + if !added_ok { + (view.content.draft, view.content.select_added) = (draft, select_added); + } + if !paragraph_ok { + view.line_editor = editor; + } + } + added_ok && paragraph_ok } /// The Fill & Sign type box commits on click-away. Save does that first, so the text is in From a525cf1d424de0cdf1dc50e34f1b6cfedcdda24e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:12:51 +0000 Subject: [PATCH 115/517] fix(js): refuse code compiled from strings that skipped the script checks eval and the Function constructors compiled code built at run time, which never passed the size and nesting checks a script passes before it runs, so a short form script could hand boa's recursive parser a deeply nested string and overflow the stack. Form scripts now refuse to compile code from strings. XFA scripts, which the runner itself starts through eval, hold such code to the same checks as the script. --- crates/js/src/lib.rs | 42 +++++++++++++++++++++++++++++++++++++++++- crates/js/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ crates/js/src/xfa.rs | 5 ++++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 785345c672..ae94feb0aa 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -27,9 +27,11 @@ use std::cell::RefCell; use std::collections::BTreeSet; use std::sync::Arc; +use boa_engine::context::HostHooks; use boa_engine::object::builtins::{JsArray, JsFunction}; use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer}; use boa_engine::property::Attribute; +use boa_engine::realm::Realm; use boa_engine::{Context, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; /// The kinds of field, as `field.type` names them. @@ -1128,8 +1130,46 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc JsResult<()> { + Err(JsNativeError::typ().with_message("scripts can't compile code from strings (eval, Function)").into()) + } +} + +/// Host hooks that hold code compiled from strings to the checks a script passes ([`refuse`]), +/// for runners that start the script itself through `eval`. +pub(crate) struct CheckedCodeFromStrings; + +impl HostHooks for CheckedCodeFromStrings { + fn ensure_can_compile_strings(&self, _: Realm, parameters: &[JsString], body: &JsString, _: bool, _: &mut Context) -> JsResult<()> { + let units = parameters.iter().chain(std::iter::once(body)).fold(0usize, |n, s| n.saturating_add(s.len())); + if units > MAX_SCRIPT_BYTES { + return Err(JsNativeError::range().with_message("the code is too long to compile").into()); + } + // As one text: brackets left open in one parameter carry into the next. + let mut source = parameters.iter().map(JsString::to_std_string_lossy).collect::>().join(","); + source.push('\n'); + source.push_str(&body.to_std_string_lossy()); + match refuse(&source) { + Some(why) => Err(JsNativeError::range().with_message(why).into()), + None => Ok(()), + } + } +} + fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { - let mut ctx = Context::default(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => { + let error = Some(format!("the script engine could not start: {e}")); + return Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error, ..Default::default() }; + } + }; ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); ctx.insert_data(RefCell::new(Host { diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 7a9efb5dfd..5b6adef53c 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -313,6 +313,23 @@ fn ordinary_nesting_still_runs() { assert_eq!(go(&s, &Event::field("Calculate", "a", "")).value, "1000"); } +/// Code built from strings at run time would reach the parser without the size and nesting +/// checks the script itself passed, so `eval` and the `Function` constructors refuse it. +#[test] +fn scripts_cannot_compile_code_from_strings() { + for s in [ + "event.value = eval('1 + 1');", + "event.value = (0, eval)('1 + 1');", + "event.value = new Function('return 2')();", + "event.value = Function('a', 'return a')(2);", + "event.value = (function* () {}).constructor('yield 2')().next().value;", + "event.value = new Array(1001).join('(') + '1' + new Array(1001).join(')'); event.value = eval(event.value);", + ] { + let o = go(s, &Event::field("Calculate", "a", "")); + assert!(o.error.as_deref().is_some_and(|e| e.contains("compile code from strings")), "{s}: {o:?}"); + } +} + mod xfa_model { use crate::Limits; use crate::xfa::*; @@ -398,6 +415,22 @@ mod xfa_model { assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]); } + /// The script runs through `eval`, so code built from strings at run time is held to the + /// script's own size and nesting checks instead of being refused outright. + #[test] + fn code_built_from_strings_is_checked_like_the_script() { + let total = "form1[0].page1[0].total[0]"; + assert_eq!(go("eval('qty.rawValue * 2')", total, "calculate").result.as_deref(), Some("8")); + assert_eq!(go("new Function('return 3')()", total, "calculate").result.as_deref(), Some("3")); + for s in [ + "eval(new Array(1001).join('(') + '1' + new Array(1001).join(')'))", + "new Function(new Array(1001).join('[') + new Array(1001).join(']'))", + ] { + let o = go(s, total, "calculate"); + assert!(o.error.as_deref().is_some_and(|e| e.contains("nested too deeply")), "{s}: {o:?}"); + } + } + /// A form whose one field `d` holds the ISO date 2026-10-20, shown as 20261020. pub(super) fn dated_form() -> XfaNode { let d = XfaNode { formatted: Some("20261020".into()), ..field("d", "form1[0].page1[0].d[0]", "2026-10-20", false) }; diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 5a693db9f0..490408a106 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -1473,7 +1473,10 @@ pub(crate) fn warm_up() { /// `started` is called once the engine is ready: a time limit on the script runs from then. pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { warm_up(); - let mut ctx = Context::default(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => return XfaOutcome { error: Some(format!("the script engine could not start: {e}")), ..Default::default() }, + }; ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); let host_state = XHost::new(root, &event.target, doc); From 5dc64face0131a62712d8f414352787e4ef92617 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:23:04 +0000 Subject: [PATCH 116/517] fix(automation): keep page text from tools when copying is not allowed text_lines returned a copy-restricted document's lines, ocr_recognize returned the words it recognised, and text_edit on a paragraph echoed the paragraph's own text, all without checking the copying permission that the other text tools check. text_lines is refused like them; recognising and restyling still work with permission to change the document, and leave the text out. --- crates/automation/src/a11y.rs | 8 +++-- crates/automation/src/lib.rs | 13 ++++++-- crates/automation/src/tools.rs | 4 +-- crates/automation/tests/it/automation.rs | 38 ++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 2124549731..21bde353e9 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -626,13 +626,17 @@ impl Automation { pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { let settings = self.ocr_settings(a)?; let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let id = self.doc(a)?.id; + let doc = self.doc(a)?; + // Recognizing needs only permission to change the document; the words it found are + // returned only when copying is allowed too. + let (id, copy) = (doc.id, doc.allows_copying()); let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?; let list: Vec = found .iter() .map(|p| match &p.skipped { Some(why) => json!({ "page": p.page + 1, "skipped": why }), - None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), + None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), + None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }), }) .collect(); Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index c90240875b..522705bb15 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -435,7 +435,7 @@ impl Automation { "doc_revisions" => self.doc_revisions(&a)?, "text_lines" => { let page = self.page(&a)?; - let doc = self.doc(&a)?; + let doc = self.doc_to_copy(&a)?; let info = &doc.info.pages[page]; let r = |x: f32| (x as f64 * 100.0).round() / 100.0; let lines: Vec = doc @@ -709,7 +709,11 @@ impl Automation { let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy]; let wanted: String = text.split_whitespace().collect(); let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?; - let after = self.doc(&a)?.text_blocks(page); + let doc = self.doc(&a)?; + let after = doc.text_blocks(page); + // Without `text` the paragraph keeps the document's own words: not echoed when + // the document doesn't allow copying them. + let copy = doc.allows_copying(); // Only a paragraph that now shows exactly the requested text is the edited one. A // neighbour that overlaps the old box (overlaid text, a text layer) must not take // its number, and neither must a paragraph the edit merged it into: rewriting that @@ -717,7 +721,10 @@ impl Automation { let edited = block_overlapping(&after, moved) .filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::() == wanted)); if let Some(b) = edited.and_then(|i| after.get(i)) { - out["paragraph"] = json!({ "text": b.text, "lines": b.lines.len(), "font": b.base_font }); + out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font }); + if copy { + out["paragraph"]["text"] = json!(b.text); + } } let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after); if !stale.is_empty() { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 9ce6b12d4f..4fff59c07d 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1172,7 +1172,7 @@ pub fn tools() -> Vec { )), t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.") .with(schema(json!({ "enabled": { "type": "boolean" } }), &[])), - t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") + t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text (left out when the document's security doesn't allow copying), word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") .cmd("ocr.recognize") .with(schema( json!({ @@ -1332,7 +1332,7 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])), - t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.") + t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used, and for a paragraph its text unless the document's security doesn't allow copying. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 0ebf3a9bc0..778deaaec4 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2130,6 +2130,44 @@ fn protecting_through_tools() { ok(&mut c, "doc_open", json!({ "path": "open.pdf" })); } +/// A document that allows changes but not copying: the tools that return a page's text refuse, +/// or leave the text out where the tool also changes the page (a restyled paragraph, recognized +/// words). +#[test] +fn copy_restricted_text_stays_in_the_document() { + let dir = workdir("copy-restricted-text"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let locked = json!({ "open_password": "open", "permissions_password": "boss", "changes": "any-except-extract" }); + let mut protect = locked.clone(); + protect["doc"] = json!(doc); + ok(&mut a, "doc_protect", protect); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" })); + let re = ok(&mut a, "doc_open", json!({ "path": "locked.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + match a.call("text_lines", &json!({ "doc": re, "page": 1 })) { + Err(ToolError::Failed(m)) => assert!(m.contains("don't allow copying"), "{m}"), + other => panic!("text_lines is refused: {other:?}"), + } + let restyled = ok(&mut a, "text_edit", json!({ "doc": re, "page": 1, "paragraph": 1, "size": 30 })); + assert!(!restyled.to_string().contains("Page"), "{restyled}"); + if ok(&mut a, "ocr_status", json!({}))["available"] != true { + eprintln!("skipped the OCR part: OCR models not installed"); + return; + } + let text = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Recognised words stay private" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({ "doc": text, "folder": "scan", "dpi": 150, "pages": [1] })); + let png = std::fs::read_dir(dir.join("scan")).unwrap().next().unwrap().unwrap().path(); + let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap(); + let mut protect = locked; + protect["doc"] = json!(scan); + ok(&mut a, "doc_protect", protect); + ok(&mut a, "doc_save", json!({ "doc": scan, "path": "scan.pdf" })); + let scan = ok(&mut a, "doc_open", json!({ "path": "scan.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "ocr_recognize", json!({ "doc": scan })); + assert!(r["words"].as_u64().unwrap() >= 3, "{r}"); + assert!(r["pages"][0].get("text").is_none() && !r.to_string().to_lowercase().contains("private"), "{r}"); +} + /// A form secured with "fill-sign" changes and no open password (how secured forms are usually /// distributed): its existing signature field can be signed without the permissions password, /// the update keeps the encryption, and the signature validates after reopening. A new field From 30300ae71be58cc5e1800cdbd19c9ec432f7a0ee Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:46 +0000 Subject: [PATCH 117/517] fix(engine): compare only documents that allow copying their text Compare read both documents' words out with no check of the copying permission, and its result, report and difference comments carry that text, so a copy-restricted document's text could be taken out through a comparison. Both documents must now allow copying. --- crates/engine/src/compare.rs | 5 ++++- crates/engine/src/tests.rs | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 50508e20ea..c23db91f1f 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -46,10 +46,13 @@ impl crate::Document { } impl Session { - /// Compare files: the text differences from document `old` to document `new`. + /// Compare files: the text differences from document `old` to document `new`. The result + /// carries both documents' text, so both must allow copying it. pub fn compare(&self, old: DocId, new: DocId) -> Result { let a = self.get(old).ok_or(EditError::NoDocument)?; let b = self.get(new).ok_or(EditError::NoDocument)?; + a.check_copying()?; + b.check_copying()?; Ok(pdfcraft_compare::compare(&a.words(), &b.words())) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0df176226a..f93e765260 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1311,6 +1311,12 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() { let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); assert!(s.get(oid).unwrap().allows_copying()); assert!(extracting.iter().all(|c| enabled(&s, oid, c))); + // Comparing reads both documents' text out, into the result, the report and the comments. + let refused = || Err(EditError::NotPermitted("copying text and images")); + assert_eq!((s.compare(id, oid).map(|_| ()), s.compare(oid, id).map(|_| ())), (refused(), refused())); + assert_eq!(s.compare_report(oid, id).map(|_| ()), refused()); + assert!(s.compare(oid, oid).is_ok()); + assert_eq!(s.mark_differences(id, oid).map(|_| ()), refused()); // Unencrypted documents allow everything. let (s, plain) = session_with(1); assert!(s.get(plain).unwrap().check_copying().is_ok() && extracting.iter().all(|c| enabled(&s, plain, c))); From 28ab616eb62a7ddc13b32895cd2215146f7ff812 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:55 +0000 Subject: [PATCH 118/517] fix(engine): print as low-resolution pictures when only that is allowed Printing checked the print permission but not the separate high-quality one (Table 22, bit 12), so a document limited to low-resolution printing came out as its original vector pages, text included. Such a document now always prints as pictures of at most 150 dpi, whatever the settings ask for. --- crates/engine/src/lib.rs | 12 +++++++++++- crates/engine/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e20cbc596e..fc1a8d2074 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -232,6 +232,10 @@ pub struct DocId(pub u64); /// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. const MAX_UNDO: usize = 100; +/// The highest resolution a document that allows only low-resolution printing (Table 22, bit 12 +/// clear) is printed at. +const LOW_RESOLUTION_PRINT_DPI: f64 = 150.0; + /// The passwords that go with one state of a document (protection can change them). #[derive(Clone, Debug, Default, PartialEq)] struct Keys { @@ -4522,9 +4526,15 @@ impl Session { if !doc.allows_printing() { return Err(EditError::NotPermitted("printing")); } + // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages + // always go as pictures, at no more than the low resolution. + let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { + dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), + grayscale: settings.raster.is_some_and(|r| r.grayscale), + }); // Print as image: the pages used become pictures first, then the picture document is // imposed in their place (its page k is the k-th distinct source page used). - if let Some(raster) = &settings.raster { + if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { let mut distinct: Vec = Vec::new(); for &p in &settings.pages { if !distinct.contains(&p) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f93e765260..2d67305445 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1340,6 +1340,38 @@ fn print_restricted_documents_disable_print_unless_opened_by_the_owner() { assert!(enabled(&s, low, "print.dialog"), "low-resolution printing is printing"); } +/// Table 22, bit 12 clear: printing is limited to a low-level representation. Whatever the +/// settings ask for, the print-ready PDF carries each page as a picture of at most 150 dpi, with +/// no text or vector artwork left; the owner password lifts the limit. +#[test] +fn low_resolution_printing_prints_pictures_of_at_most_150_dpi() { + let bytes = protected("", "owner", -1 ^ (1 << 11)); + let mut s = Session::new(); + let low = s.open("low.pdf", None, bytes.clone(), None).unwrap(); + let width = f64::from(s.get(low).unwrap().info.pages[0].width); + let actual = print::Settings { pages: vec![0], layout: print::Layout::Size(print::SizeMode::Actual), ..Default::default() }; + for raster in [None, Some(print::Raster { dpi: 600.0, grayscale: false })] { + let printed = s.print_pdf(low, &print::Settings { raster, ..actual.clone() }).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("print.pdf", None, Arc::new(printed.clone()), None).unwrap(); + assert!(page_texts(&s2, id2).iter().all(|t| t.trim().is_empty()), "{raster:?}: no text is left"); + let doc = pdfcraft_cos::Document::open(Arc::new(printed)).unwrap(); + let widths: Vec = doc + .scan_objects() + .filter_map(|(_, o)| match &*o { + pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image") => st.dict.int(b"Width"), + _ => None, + }) + .collect(); + assert!(!widths.is_empty() && widths.iter().all(|&w| w as f64 <= width * 150.0 / 72.0 + 1.0), "{raster:?}: {widths:?} px for {width} pt"); + } + let owner = s.open("low.pdf", None, bytes, Some("owner")).unwrap(); + let printed = s.print_pdf(owner, &actual).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("print.pdf", None, Arc::new(printed), None).unwrap(); + assert!(page_texts(&s2, id2).iter().any(|t| !t.trim().is_empty()), "the owner prints the pages themselves"); +} + #[test] fn the_permissions_password_unlocks_an_open_document() { // Only a permissions password (#995, #1077): opens without asking. Changes are allowed, From 73f11b2a29075094381d0a9d4c1bbcdb90627c8e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:55 +0000 Subject: [PATCH 119/517] fix(engine): fail an action whose information-removal step fails A step that applies an edit logged any error and carried on, which is right for a flatten with nothing to flatten but not for removing hidden information or sanitizing: on a signed file the removal was refused, and the unchanged file was written out as the action's successful result. Those steps now fail the action unless there was nothing to remove. --- crates/engine/src/actions.rs | 6 +++++- crates/engine/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/actions.rs b/crates/engine/src/actions.rs index f679cf87cd..f7d11d47ed 100644 --- a/crates/engine/src/actions.rs +++ b/crates/engine/src/actions.rs @@ -191,9 +191,13 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> return Ok(None); } }; - // Steps that find nothing to do (no comments to flatten, …) aren't failures. + // Steps that find nothing to do (no comments to flatten, …) aren't failures. A removal step + // that didn't remove is: the file would be passed on as cleaned with the data still in it. + let removes = matches!(step, Step::RemoveHiddenInformation | Step::Sanitize); match s.apply(id, edit) { Ok(()) => log.push(step.label().to_string()), + Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) if removes => log.push(format!("{}: nothing to remove", step.label())), + Err(e) if removes => return Err(e), Err(e) => log.push(format!("{}: {e}", step.label())), } Ok(None) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2d67305445..217d8d166b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4274,6 +4274,24 @@ fn actions_run_their_steps_on_files() { assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), |_, _| {}).is_err()); } +/// A removal step that fails (here: a signed file can't be rewritten without its hidden data) +/// fails the action, so the file is not passed on as cleaned. +#[test] +fn actions_fail_when_hidden_information_could_not_be_removed() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(2)), None).unwrap(); + s.apply(id, Edit::SetInfo { key: "Author".into(), value: "Private Name".into() }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, None).unwrap(); + let signed = s.sign(id, &digital_id, SignOptions { page: 1, ..SignOptions::default() }).unwrap(); + let built = actions::builtin(); + let sanitize = built.iter().find(|a| a.name == "Sanitize Documents").unwrap(); + let e = actions::run_on(sanitize, "signed.pdf", signed, |_, _| {}).map(|r| r.log).unwrap_err(); + assert!(e.starts_with("Sanitize document:"), "{e}"); +} + #[test] fn exporting_office_files_keeps_images() { let mut s = Session::new(); From c3bc881607b71be14cdccbba50fc5e81eb54247d Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:39:40 +0000 Subject: [PATCH 120/517] fix(platform): a saved file keeps its group along with its mode The atomic writer gave the new file the replaced file's mode but left it in the saver's own group, so a file readable by one group became readable by another. The staging file joins the replaced file's group before it gets the mode and before anything is written; where it can't, group and others get no access. --- crates/platform/src/staging.rs | 69 +++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index 0b727449cb..f6a98c4fe4 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -31,16 +31,16 @@ pub fn create_staging( create_staging_as(dir, name, form, suffixes, None) } -/// [`create_staging`], giving the new file the permissions `keep` on Unix. It is created readable -/// and writable by its owner only and then given `keep`, so its mode is never more open than that -/// of the file it will replace, not even while it is still empty (a handle opened then would keep -/// reading what is written later). Only the mode is copied: the owner and group are the saver's. +/// [`create_staging`], giving the new file the mode and group of the file `keep` describes on +/// Unix. It is created readable and writable by its owner only and then given them, so its mode +/// is never more open than that of the file it will replace, not even while it is still empty (a +/// handle opened then would keep reading what is written later). The owner is the saver's. fn create_staging_as( dir: &Path, name: &str, form: StagingName, suffixes: impl IntoIterator, - keep: Option<&std::fs::Permissions>, + keep: Option<&std::fs::Metadata>, ) -> std::io::Result<(PathBuf, std::fs::File)> { // At most 128 bytes of the target's name, cut between characters, so the staging name fits // the 255-byte (Linux, macOS) and 255-unit (Windows) limits however long that name is. @@ -68,7 +68,7 @@ fn create_staging_as( if let Some(keep) = keep { // A volume that can't change modes (FAT, some network shares) leaves the file // owner-only: stricter than the file it replaces, never looser. - let _ = file.set_permissions(keep.clone()); + let _ = file.set_permissions(kept_permissions(&file, keep)); } return Ok((tmp, file)); } @@ -81,6 +81,26 @@ fn create_staging_as( Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every temporary file name tried is taken")) } +/// The permissions a staging file gets to replace the file `keep` describes. The mode's group bits +/// are meant for that file's group, so the staging file joins it first. Where it can't (the saver +/// isn't a member), group and others get nothing: someone the group bits shut out would +/// otherwise read the file as one of the others. +#[cfg(unix)] +fn kept_permissions(file: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::Permissions { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let mut mode = keep.permissions(); + let joined = file.metadata().is_ok_and(|m| m.gid() == keep.gid()) || std::os::unix::fs::fchown(file, None, Some(keep.gid())).is_ok(); + if !joined { + mode.set_mode(mode.mode() & !0o077); + } + mode +} + +#[cfg(not(unix))] +fn kept_permissions(_: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::Permissions { + keep.permissions() +} + /// Unpredictable staging-name suffixes, so a name can't be planted in advance. Each is the /// standard library's keyed hash of a counter (`RandomState::hash_one`, currently SipHash-1-3), /// keyed from the operating system's random source. That is all a staging name needs, but it is @@ -98,7 +118,7 @@ pub fn staging_suffixes() -> impl Iterator { /// and the target is untouched until the new file is complete. The target's folder must exist; /// a folder at the target is refused by the rename. Flushing is the staging file's own /// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file -/// that is replaced keeps its permissions, so a private file stays private; a new file gets the +/// that is replaced keeps its permissions and group, so a private file stays private; a new file gets the /// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where /// the volume stores one. /// On macOS, accessible extended attributes of an existing regular destination are copied @@ -126,9 +146,9 @@ pub fn write_atomically_with( } let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("save"); - // A file that is replaced keeps its permissions (#1790). Unix modes only: Windows is as before. + // A file that is replaced keeps its permissions (#1790) and group. Unix only: Windows is as before. #[cfg(unix)] - let keep = std::fs::metadata(path).ok().map(|m| m.permissions()); + let keep = std::fs::metadata(path).ok(); #[cfg(not(unix))] let keep = None; // A saved file is the same document: it keeps its creation date (#2658). @@ -583,6 +603,37 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A replaced file keeps its group as well as its mode, so group permissions never reach a + /// group that couldn't read the file before (the saver's own group, by default). + #[cfg(target_os = "linux")] + #[test] + fn a_replaced_file_keeps_its_group() { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let dir = test_dir("group"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + let created = std::fs::metadata(&target).unwrap().gid(); + let status = std::fs::read_to_string("/proc/self/status").unwrap(); + let groups = status.lines().find_map(|l| l.strip_prefix("Groups:")).unwrap_or_default(); + let Some(other) = groups.split_whitespace().filter_map(|g| g.parse::().ok()).find(|&g| g != created) else { + eprintln!("skipped: the test needs a second group"); + return; + }; + std::os::unix::fs::chown(&target, None, Some(other)).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640)).unwrap(); + let mut staged = (0, 0); + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { + let m = f.metadata()?; + staged = (m.gid(), m.permissions().mode() & 0o777); + Ok(()) + }) + .unwrap(); + assert_eq!(staged, (other, 0o640), "the staging file has the target's group before it is flushed"); + let m = std::fs::metadata(&target).unwrap(); + assert_eq!((m.gid(), m.permissions().mode() & 0o777), (other, 0o640)); + let _ = std::fs::remove_dir_all(&dir); + } + /// #2658: a save replaces the file, but the document is the same one: it keeps its creation /// date where the system lets a program set it. #[cfg(any(windows, target_os = "macos"))] From 77480498b25f34028198c32f15afb50516664a73 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:04:07 +0000 Subject: [PATCH 121/517] fix(engine): low-resolution printing prints only the chosen content Pages printed as pictures were drawn with everything on them, so form fields only printed the page content, and the document with stamps printed every comment. The pictures are drawn from a copy holding only what the vector print draws. --- crates/engine/src/lib.rs | 3 +- crates/engine/src/print_raster.rs | 26 +++++++---- crates/engine/src/tests.rs | 77 +++++++++++++++++++++++++++++++ crates/print/src/lib.rs | 19 ++++++++ 4 files changed, 114 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fc1a8d2074..817eb79a96 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4541,8 +4541,7 @@ impl Session { distinct.push(p); } } - let hide_comments = matches!(settings.content, print::Content::Document | print::Content::FormFieldsOnly); - let bytes = print_raster::rasterize(doc, &distinct, raster, hide_comments).map_err(EditError::Print)?; + let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; let remapped = print::Settings { pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs index 117c8e14cc..4ed5895860 100644 --- a/crates/engine/src/print_raster.rs +++ b/crates/engine/src/print_raster.rs @@ -1,25 +1,33 @@ //! Print as image (Acrobat's Advanced Print Setup ▸ Print As Image): every page //! sent to the printer as a picture at a chosen resolution, for printers that skip text, //! shading or transparency. The pages are rendered the way the viewer shows them (reading -//! colours off, the print's own colours), made into an image-only document, and that document -//! is imposed on the paper like any other. The print is no longer searchable text. +//! colours off, the print's own colours) with only what the print's content choice keeps, made +//! into an image-only document, and that document is imposed on the paper like any other. The +//! print is no longer searchable text. +use pdfcraft_cos::{SaveOptions, write_incremental}; use pdfcraft_create::ImageResolution; -use pdfcraft_print::Raster; +use pdfcraft_print::{Content, Raster}; use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderRequest}; -use crate::Document; use crate::export::encode_png; +use crate::{Document, guard}; /// `doc`'s pages `pages` (0-based, each once, in the order given) as an image-only PDF, one -/// page per entry, the size of the source page as displayed. Pages too large for the renderer -/// at `raster.dpi` render at the largest resolution it allows. -pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, hide_comments: bool) -> Result, String> { +/// page per entry, the size of the source page as displayed, drawing what `content` prints. +/// Pages too large for the renderer at `raster.dpi` render at the largest resolution it allows. +pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Content) -> Result, String> { let dpi = raster.dpi.clamp(72.0, 600.0); let src = doc.export_source(); let mut config = src.config.clone(); - config.hide_comments = hide_comments; - let mut renderer = PageRenderer::new(src.bytes, config); + // The copy below holds only what prints, so the viewer's own comment hiding plays no part. + config.hide_comments = false; + let printed = guard(|| -> Result, String> { + let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?; + pdfcraft_print::keep_printed(&mut cos, content); + write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string()) + })??; + let mut renderer = PageRenderer::new(std::sync::Arc::new(printed), config); let mut images: Vec<(String, Vec)> = Vec::with_capacity(pages.len()); let mut used: Option = None; for &page in pages { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 217d8d166b..3d347e6492 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1372,6 +1372,83 @@ fn low_resolution_printing_prints_pictures_of_at_most_150_dpi() { assert!(page_texts(&s2, id2).iter().any(|t| !t.trim().is_empty()), "the owner prints the pages themselves"); } +/// A 100 × 400 pt page with one band per kind of thing that prints, top to bottom: page content +/// (blue), a filled text field (green), a stamp (red) and a square comment (yellow). +fn print_content_pdf() -> Vec { + let content = "0 0 1 rg 0 300 100 100 re f"; + let ap = |colour: &str| { + let ops = format!("{colour} rg 0 0 100 100 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >> stream\n{ops}\nendstream", ops.len()) + }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 400] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /V (Filled) /F 4 /P 3 0 R /Rect [0 200 100 300] /AP << /N 8 0 R >> >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Stamp /F 4 /P 3 0 R /Rect [0 100 100 200] /AP << /N 9 0 R >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Square /F 4 /P 3 0 R /Rect [0 0 100 100] /AP << /N 10 0 R >> >> endobj\n\ + 8 0 obj {} endobj\n\ + 9 0 obj {} endobj\n\ + 10 0 obj {} endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + ap("0 1 0"), + ap("1 0 0"), + ap("1 1 0"), + ) + .into_bytes() +} + +/// Low-resolution printing prints what the chosen content prints when the pages themselves are +/// printed: form fields only leaves out the page content and every comment, the document alone +/// leaves out the comments, and the document with stamps keeps only the stamps. +#[test] +fn low_resolution_printing_prints_only_the_chosen_content() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(print_content_pdf())).unwrap(); + cos.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ (1 << 11), + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); + let mut s = Session::new(); + let low = s.open("low.pdf", None, bytes.clone(), None).unwrap(); + let owner = s.open("low.pdf", None, bytes, Some("owner")).unwrap(); + // Each band's centre, in pixels from the top of the sheet at 72 dpi, and its colour. + let bands = [("content", 50, [0, 0, 255]), ("field", 150, [0, 255, 0]), ("stamp", 250, [255, 0, 0]), ("comment", 350, [255, 255, 0])]; + let all: &[&str] = &["content", "field", "stamp", "comment"]; + for (content, shown) in [ + (print::Content::DocumentAndMarkups, all), + (print::Content::Document, &["content", "field"][..]), + (print::Content::DocumentAndStamps, &["content", "field", "stamp"][..]), + (print::Content::FormFieldsOnly, &["field"][..]), + ] { + let settings = print::Settings { + pages: vec![0], + paper: (100.0, 400.0), + layout: print::Layout::Size(print::SizeMode::Actual), + content, + ..Default::default() + }; + for (id, how) in [(owner, "pages"), (low, "pictures")] { + let (w, h, rgba) = printed_pixels(s.print_pdf(id, &settings).unwrap()); + assert_eq!((w, h), (100, 400), "{content:?} as {how}"); + for (band, y, colour) in bands { + let px = rgba.get((y * w as usize + 50) * 4..).and_then(|p| p.get(..3)).unwrap(); + let near = |c: [u8; 3]| px.iter().zip(c).all(|(&a, b)| a.abs_diff(b) < 40); + let expected = shown.contains(&band); + assert!(if expected { near(colour) } else { near([255; 3]) }, "{content:?} as {how}: {band} drawn {expected} expected, pixel {px:?}"); + } + } + } +} + #[test] fn the_permissions_password_unlocks_an_open_document() { // Only a permissions password (#995, #1077): opens without asking. Changes are allowed, diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index e935cb938e..6839e5f6b0 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -696,6 +696,25 @@ fn decoded_contents(doc: &Document, page: &Dict) -> Vec { out } +/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that +/// don't print are dropped, and with [`Content::FormFieldsOnly`] the page content too. For +/// printing pages as pictures, where a renderer draws each page as it stands. +pub fn keep_printed(doc: &mut Document, content: Content) { + for page in pdfcraft_model::pages(doc) { + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let kept: Vec = annots.into_iter().filter(|a| doc.resolve(a).as_dict().is_some_and(|d| prints(doc, d, content))).collect(); + // Fails only when the page object isn't a dictionary, and such a page draws nothing. + let _ = doc.update_dict(page.obj, |d| { + if content == Content::FormFieldsOnly { + d.remove(b"Contents"); + } + if d.contains(b"Annots") { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + }); + } +} + /// Does annotation `d` print under `content`? fn prints(doc: &Document, d: &Dict, content: Content) -> bool { const HIDDEN: i64 = 2; From 2e9b247f3fd16ae51c717781c15cc4698af5ca1d Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:16:15 +0000 Subject: [PATCH 122/517] fix(js): read printd literals in linear time util.printd measured the run of identical characters at every position of the format, literals included, but copied a literal one character at a time. A format with a long stretch of one literal character rescanned the rest of that stretch at every step: quadratic work from one script-built string. Only the date letters are read as runs now. --- crates/js/src/lib.rs | 10 +++++++++- crates/js/src/tests.rs | 11 +++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index ae94feb0aa..156eb9fbe3 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -752,7 +752,11 @@ fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult let mut i = 0; while i < b.len() { let c = b[i]; - let run = b[i..].iter().take_while(|x| **x == c).count(); + // Only date letters read as runs; a literal is copied one character at a time, so + // measuring its run would rescan a long literal stretch at every position. + let run = if b"ymdHhMst".contains(&c) { b[i..].iter().take_while(|x| **x == c).count() } else { 1 }; + #[cfg(test)] + PRINTD_SCANNED.fetch_add(run as u64, std::sync::atomic::Ordering::Relaxed); let push_num = |out: &mut String, v: i64, n: usize| { if n >= 2 { out.push_str(&format!("{v:02}")); @@ -1011,6 +1015,10 @@ fn event_object(ctx: &mut Context, e: &Event) -> JsObject { // stack a script can use: brackets and prefix operators by the limits below, and every other // construct (a few KiB per level, several bytes of source each) by the length limit. +/// Bytes `util.printd` has scanned for runs of a format character, for tests that bound its work. +#[cfg(test)] +pub(crate) static PRINTD_SCANNED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + /// Longest script, in bytes. pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; /// Largest width or precision accepted by `util.printf`. diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 5b6adef53c..13b51ba47b 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -244,6 +244,17 @@ fn document_requests_and_console() { ); } +#[test] +fn printd_reads_a_long_literal_run_in_linear_work() { + crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed); + let o = go("event.value = util.printd(new Array(20001).join('x'), new Date(2024, 0, 5)).length;", &Event::field("Calculate", "total", "")); + assert_eq!(o.error, None); + assert_eq!(o.value, "20000"); + // Other tests format short dates at the same time; they add a few dozen bytes at most. + let scanned = crate::PRINTD_SCANNED.load(std::sync::atomic::Ordering::Relaxed); + assert!(scanned < 40_000, "printd scanned {scanned} bytes for a 20000-byte format"); +} + #[test] fn document_level_functions_are_available() { let o = run( From a5cfc3eec43d6c615bc34852d828deac4a079a82 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:16:27 +0000 Subject: [PATCH 123/517] fix(js): cap a form script's alerts, console lines and requests The AcroForm script host kept every alert, console line and request a script made, each as long as the script made it. A loop printing a long script-built string held a copy per iteration. The host applies the XFA host's limits: message and console counts, the effect count for requests, each message, URL and field name cut to a line, and a byte budget across all of them, with a closing console line saying how many were left out. --- crates/js/src/lib.rs | 104 +++++++++++++++++++++++++++++++++++------ crates/js/src/tests.rs | 29 ++++++++++++ crates/js/src/xfa.rs | 5 +- 3 files changed, 124 insertions(+), 14 deletions(-) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 156eb9fbe3..f2221f7509 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -203,6 +203,66 @@ struct Host { console: Vec, requests: Vec, doc: DocInfo, + /// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`]. + kept: usize, + /// Alerts, console lines and requests the limits left out. + dropped: usize, +} + +impl Host { + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= xfa::MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped = self.dropped.saturating_add(1); + false + } + } + } + + fn alert(&mut self, m: String) { + if self.alerts.len() >= xfa::MAX_ALERTS { + self.dropped = self.dropped.saturating_add(1); + } else if self.room(m.len()) { + self.alerts.push(m); + } + } + + fn print(&mut self, m: String) { + if self.console.len() >= xfa::MAX_CONSOLE { + self.dropped = self.dropped.saturating_add(1); + } else if self.room(m.len()) { + self.console.push(m); + } + } + + /// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`] + /// requests and the run's byte budget. + fn request(&mut self, r: Request) { + if self.requests.len() >= xfa::MAX_EFFECTS { + self.dropped = self.dropped.saturating_add(1); + return; + } + let r = match r { + Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()), + Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)), + Request::Submit(u) => Request::Submit(xfa::clip(u)), + Request::Focus(n) => Request::Focus(xfa::clip(n)), + other => other, + }; + let bytes = match &r { + Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), + _ => 0, + }; + if self.room(bytes) { + self.requests.push(r); + } + } } type Shared = RefCell; @@ -223,6 +283,19 @@ fn text(v: &JsValue, ctx: &mut Context) -> JsResult { Ok(v.to_string(ctx)?.to_std_string_escaped()) } +/// [`text`] cut to a message's length ([`xfa::clip`]), without first copying all of a longer +/// string. +fn text_line(v: &JsValue, ctx: &mut Context) -> JsResult { + if v.is_undefined() || v.is_null() { + return Ok(String::new()); + } + let js = v.to_string(ctx)?; + // Two UTF-16 units per character at most: the clip still sees more than a line's worth. + let keep = xfa::MAX_LINE.saturating_add(1).saturating_mul(2); + let js = if js.len() > keep { js.get(..keep).unwrap_or_default() } else { js }; + Ok(xfa::clip(js.to_std_string_escaped())) +} + fn arg(args: &[JsValue], i: usize) -> JsValue { args.get(i).cloned().unwrap_or_default() } @@ -440,7 +513,7 @@ fn field_object(ctx: &mut Context, name: &str) -> JsObject { Ok(JsValue::undefined()) } fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - host(c)?.borrow_mut().requests.push(Request::Focus(name.to_std_string_escaped())); + host(c)?.borrow_mut().request(Request::Focus(name.to_std_string_escaped())); Ok(JsValue::undefined()) } @@ -494,12 +567,12 @@ fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsRes fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let names = strings(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().requests.push(Request::Reset(names)); + host(ctx)?.borrow_mut().request(Request::Reset(names)); Ok(JsValue::undefined()) } fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().requests.push(Request::Print); + host(ctx)?.borrow_mut().request(Request::Print); Ok(JsValue::undefined()) } @@ -512,7 +585,7 @@ fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult text(&a, ctx)?, }; - host(ctx)?.borrow_mut().requests.push(Request::Submit(url)); + host(ctx)?.borrow_mut().request(Request::Submit(url)); Ok(JsValue::undefined()) } @@ -534,38 +607,38 @@ fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { match v.as_object() { Some(o) if !o.is_array() && !o.is_callable() => { let m = o.get(JsString::from(key), ctx)?; - text(&m, ctx) + text_line(&m, ctx) } - _ => text(v, ctx), + _ => text_line(v, ctx), } } fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = message(&arg(args, 0), "cMsg", ctx)?; - host(ctx)?.borrow_mut().alerts.push(m); + host(ctx)?.borrow_mut().alert(m); // The OK button. Ok(JsValue::from(1)) } fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().requests.push(Request::Beep); + host(ctx)?.borrow_mut().request(Request::Beep); Ok(JsValue::undefined()) } fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = message(&arg(args, 0), "cURL", ctx)?; - host(ctx)?.borrow_mut().requests.push(Request::LaunchUrl(u)); + host(ctx)?.borrow_mut().request(Request::LaunchUrl(u)); Ok(JsValue::undefined()) } @@ -575,8 +648,8 @@ fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { } fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = text(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().console.push(m); + let m = text_line(&arg(args, 0), ctx)?; + host(ctx)?.borrow_mut().print(m); Ok(JsValue::undefined()) } @@ -1187,6 +1260,8 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc JsResult<()> { @@ -1217,6 +1292,9 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc 0 { + out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped)); + } } out } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 13b51ba47b..07ae8ecd05 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -244,6 +244,35 @@ fn document_requests_and_console() { ); } +/// Bytes of text a run handed back in its messages, console lines and requests. +fn kept_bytes(o: &Outcome) -> usize { + let req = |r: &Request| match r { + Request::Reset(names) => names.iter().map(String::len).sum(), + Request::Submit(u) | Request::LaunchUrl(u) | Request::Focus(u) => u.len(), + _ => 0, + }; + o.console.iter().chain(&o.alerts).map(String::len).sum::() + o.requests.iter().map(req).sum::() +} + +#[test] +fn console_alerts_and_requests_are_capped_in_count_and_bytes() { + let o = go( + "var s = 'x'; while (s.length < 8192) s += s; \ + for (var i = 0; i < 1500; i++) { console.println(s); app.alert(s); } \ + var a = []; for (var i = 0; i < 2000; i++) a.push(s); \ + for (var i = 0; i < 5; i++) this.resetForm(a); \ + for (var i = 0; i < 12000; i++) app.launchURL('u');", + &Event::field("Mouse Up", "agree", ""), + ); + assert_eq!(o.error, None); + assert!(o.console.len() <= crate::xfa::MAX_CONSOLE + 1, "{} console lines", o.console.len()); + assert!(o.alerts.len() <= crate::xfa::MAX_ALERTS, "{} alerts", o.alerts.len()); + assert!(o.requests.len() <= crate::xfa::MAX_EFFECTS, "{} requests", o.requests.len()); + assert!(o.console.iter().chain(&o.alerts).all(|m| m.chars().count() <= 4_097)); + assert!(kept_bytes(&o) <= crate::xfa::MAX_PAYLOAD, "{} bytes kept", kept_bytes(&o)); + assert!(o.console.last().is_some_and(|l| l.contains("left out")), "{:?}", o.console.last()); +} + #[test] fn printd_reads_a_long_literal_run_in_linear_work() { crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed); diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 490408a106..e875023d60 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -158,7 +158,10 @@ pub const MAX_ALERTS: usize = 100; /// Most console lines one run may print. pub const MAX_CONSOLE: usize = 1_000; /// Longest message or console line kept, in characters. -const MAX_LINE: usize = 4_096; +pub(crate) const MAX_LINE: usize = 4_096; +/// Most bytes of text one run may hand back or write: messages, console lines, URLs, names +/// and the values it sets. Past it, more are left out and noted. +pub(crate) const MAX_PAYLOAD: usize = 32 << 20; /// Longest value a script may put in a field (characters); longer ones are cut and noted. const MAX_VALUE: usize = 65_536; From 72fd2a1b24bdbeff51421165bbcb1230bf3da48b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:18:45 +0000 Subject: [PATCH 124/517] fix(js): bound the text an XFA script's effects and values hold The XFA script host capped the number of effects but not their size: URLs, focus targets and resetData lists were kept whole, a resetData list kept every repeated name, and values written to fields were each cut but not counted together. A loop repeating one long script-built string kept a copy per effect or field. URLs, focus targets and reset names are cut to a message's length, reset lists name each field once, and every effect, console line and value written charges a byte budget for the run; past it they are left out and noted. FormCalc charges an assignment to many fields once per field copied. --- crates/js/src/formcalc.rs | 8 ++-- crates/js/src/tests.rs | 50 +++++++++++++++++++++++ crates/js/src/xfa.rs | 84 ++++++++++++++++++++++++++++++++++----- 3 files changed, 129 insertions(+), 13 deletions(-) diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index a29397339e..7a3de5a70f 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -15,7 +15,7 @@ use std::rc::Rc; use crate::Limits; use crate::xfa::{ HNode, XHost, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome, child_named, clip, clip_result, descendants_named, instances, - resolve_som, run_within, + reset_names, resolve_som, run_within, }; /// Deepest nesting the parser follows. @@ -1466,8 +1466,7 @@ impl Interp<'_> { } "resetdata" => { let list = self.arg_str(args, 0)?; - let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); - self.h.push(XfaEffect::ResetData(names)); + self.h.push(XfaEffect::ResetData(reset_names(&list))); Ok(Val::Null) } "print" => { @@ -1646,6 +1645,8 @@ impl Interp<'_> { return Err(format!("{som} has no {other} to assign to")); } for k in kids { + // Every destination copies the value: charge each copy. + self.charge(self.cost(&v))?; self.assign_to(Val::Node(k), "rawValue", v.clone())?; } } @@ -1654,6 +1655,7 @@ impl Interp<'_> { } Val::List(items) => { for it in items { + self.charge(self.cost(&v))?; self.assign_to(it, prop, v.clone())?; } Ok(()) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 07ae8ecd05..8ce338b2e0 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -665,6 +665,22 @@ mod xfa_model { assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097)); } + #[test] + fn urls_and_reset_lists_are_clipped_and_deduplicated() { + let o = go( + "var s = 'x'; while (s.length < 65536) s += s; for (var i = 0; i < 600; i++) xfa.host.gotoURL(s + i); \ + var r = 'a,'; while (r.length < 262144) r += r; xfa.host.resetData(r);", + "form1[0].page1[0]", + "initialize", + ); + assert_eq!(o.error, None); + let urls: Vec<&String> = o.effects.iter().filter_map(|e| if let XfaEffect::LaunchUrl(u) = e { Some(u) } else { None }).collect(); + assert_eq!(urls.len(), 600); + assert!(urls.iter().all(|u| u.chars().count() <= 4_097)); + let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum(); + assert_eq!(names, 1); + } + #[test] fn a_slow_engine_start_does_not_count_against_the_script() { let ms = std::time::Duration::from_millis; @@ -716,6 +732,40 @@ mod formcalc { o.result.unwrap_or_default() } + /// Assigning one long value to many fields at once stops at the run's byte budget and is + /// charged per copy, and a reset list names each field once. + #[test] + fn bulk_assignments_and_reset_lists_are_bounded() { + let f = |i: usize| super::xfa_model::field("f", &format!("form1[0].page1[0].f[{i}]"), "", false); + let root = XfaNode { + name: "form1".into(), + som: "form1[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![XfaNode { + name: "page1".into(), + som: "form1[0].page1[0]".into(), + kind: Some(XfaKind::Subform), + children: (0..1300).map(f).collect(), + ..Default::default() + }], + ..Default::default() + }; + let ev = XfaEvent { activity: "initialize".into(), target: "form1[0]".into(), ..Default::default() }; + let run = |script: &str| run_formcalc(script, &ev, &XfaDoc::default(), &root, Limits::default()); + let o = run("var s = \"x\"\nwhile (Len(s) < 65536) do s = Concat(s, s) endwhile\npage1.f = s"); + assert_eq!(o.error, None, "{o:?}"); + let kept: usize = o.effects.iter().map(|e| if let XfaEffect::SetValue { value, .. } = e { value.len() } else { 0 }).sum(); + assert!(kept <= MAX_PAYLOAD, "{kept} bytes of values kept"); + assert!(o.notes.iter().any(|n| n.contains("left out")), "{:?}", o.notes); + // 1300 copies of a 1 MiB value are more work than a script may do. + let o = run("var s = \"x\"\nwhile (Len(s) < 1048576) do s = Concat(s, s) endwhile\npage1.f = s"); + assert_eq!(o.error.as_deref(), Some("the script ran too long"), "{:?}", o.notes); + let o = run("var r = \"a,\"\nwhile (Len(r) < 262144) do r = Concat(r, r) endwhile\n$host.resetData(r)\n$host.resetData(r)"); + assert_eq!(o.error, None, "{o:?}"); + let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum(); + assert_eq!(names, 2); + } + /// FormCalc reads `formattedValue` as the field's picture shows it too, until the script /// sets the value. #[test] diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index e875023d60..ae38658e09 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -8,7 +8,7 @@ //! children, then its siblings, then each ancestor's children. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use boa_engine::object::ObjectInitializer; use boa_engine::object::builtins::{JsArray, JsProxy}; @@ -182,6 +182,36 @@ pub(crate) fn clip_value(v: String) -> (String, bool) { } } +/// The field names of a `resetData` list (comma-separated), each cut to [`MAX_LINE`] and +/// named once, at most as many as a snapshot holds nodes. +pub(crate) fn reset_names(list: &str) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for name in list.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if out.len() >= MAX_NODES { + break; + } + let name = clip(name.to_string()); + if seen.insert(name.clone()) { + out.push(name); + } + } + out +} + +/// Bytes of text `e` holds. +fn payload(e: &XfaEffect) -> usize { + match e { + XfaEffect::SetValue { som, value: s } | XfaEffect::SetPresence { som, presence: s } | XfaEffect::SetAccess { som, access: s } => { + som.len().saturating_add(s.len()) + } + XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), + XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), + XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout => 0, + } +} + /// A script's completion value (what a calculate script puts in its field) cut to /// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was. pub(crate) fn clip_result(out: &mut XfaOutcome) { @@ -235,6 +265,9 @@ pub(crate) struct XHost { clipped_values: usize, dropped_alerts: usize, dropped_console: usize, + /// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`]. + kept: usize, + dropped_bytes: usize, } impl XHost { @@ -259,15 +292,34 @@ impl XHost { clipped_values: 0, dropped_alerts: 0, dropped_console: 0, + kept: 0, + dropped_bytes: 0, + } + } + + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped_bytes = self.dropped_bytes.saturating_add(1); + false + } } } /// Record an effect: values, presence and access set again on the same object replace the - /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes) effects are counted - /// and dropped. + /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes, or [`MAX_PAYLOAD`] + /// bytes of text) effects are counted and dropped. pub(crate) fn push(&mut self, e: XfaEffect) { let e = match e { XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)), + XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)), + XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)), + XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()), XfaEffect::SetValue { som, value } => { let (value, cut) = clip_value(value); if cut { @@ -277,6 +329,9 @@ impl XHost { } other => other, }; + if !self.room(payload(&e)) { + return; + } let key = match &e { XfaEffect::SetValue { som, .. } => Some((0u8, som.clone())), XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), @@ -319,7 +374,7 @@ impl XHost { pub(crate) fn print(&mut self, line: String) { if self.console.len() >= MAX_CONSOLE { self.dropped_console += 1; - } else { + } else if self.room(line.len()) { self.console.push(line); } } @@ -336,6 +391,9 @@ impl XHost { if self.dropped_console > 0 { out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); } + if self.dropped_bytes > 0 { + out.push(format!("the script's changes and output passed {} MiB of text; {} more were left out", MAX_PAYLOAD >> 20, self.dropped_bytes)); + } if self.clipped_values > 0 { out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values)); } @@ -346,19 +404,26 @@ impl XHost { /// holds a value; writing one into a subform would put text where another viewer expects /// a data group). Returns whether it was. pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool { - let Some(n) = self.nodes.get_mut(i) else { return false }; + let Some(n) = self.nodes.get(i) else { return false }; if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { return false; } + let som = n.som.clone(); // Cut here, not only in the effect: later reads in the script see what is saved, and // the snapshot stays bounded however often a long value is written. let (text, cut) = clip_value(text); - n.value = text.clone(); - n.formatted = None; - let som = n.som.clone(); if cut { self.clipped_values += 1; } + // The snapshot's copy counts as well as the effect's: past the budget the write is left + // out of both. + if !self.room(text.len()) { + return true; + } + if let Some(n) = self.nodes.get_mut(i) { + n.value = text.clone(); + n.formatted = None; + } self.push(XfaEffect::SetValue { som, value: text }); true } @@ -1027,8 +1092,7 @@ fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { let list = text(&arg(args, 0), ctx)?; - let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); - effect(ctx, XfaEffect::ResetData(names))?; + effect(ctx, XfaEffect::ResetData(reset_names(&list)))?; Ok(JsValue::undefined()) } From 17f5d80f44cdd0fe58f035adc45ddc8f8a02b95b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:20:22 +0000 Subject: [PATCH 125/517] fix(xfa): break a long word in linear time Text layout broke a word wider than its line by measuring the whole growing piece after every character, and counting its characters again each time. A long unbroken word in a wide box with a small font made that quadratic. The piece's advance is kept as a running sum of character widths instead. --- crates/xfa/src/tests.rs | 13 +++++++++++++ crates/xfa/src/text.rs | 18 ++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 84c9fc00f8..17e286d6c5 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2657,3 +2657,16 @@ fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() { assert!((left_edge("center", "TITLE ") - (36.0 + (w - ink) / 2.0)).abs() < 1e-6); assert!((left_edge("right", "TITLE ") - (36.0 + w - ink)).abs() < 1e-6); } + +/// A word longer than its line is broken by character in work linear in its length. +#[test] +fn a_long_word_breaks_in_linear_work() { + let face = text::Face::of(&Font { typeface: "Arial".into(), size: 1.0, ..Font::default() }); + let word = "i".repeat(20_000); + text::MEASURED.with(|m| m.set(0)); + let b = text::layout(&text::plain(&word), Rect::new(0.0, 0.0, 1000.0, 0.0), &Para::default(), &face, &|_| String::new()); + let measured = text::MEASURED.with(|m| m.get()); + assert!(b.spans.len() > 2, "{} lines", b.spans.len()); + assert_eq!(b.spans.iter().map(|s| s.text.len()).sum::(), 20_000); + assert!(measured < 200_000, "{measured} bytes measured for a 20000-byte word"); +} diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index 1598a40345..fc09d355e7 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -123,6 +123,8 @@ impl Face { /// Advance of `s` at this face's size. pub fn width(&self, s: &str) -> f64 { + #[cfg(test)] + MEASURED.with(|m| m.set(m.get().saturating_add(s.len() as u64))); let designed = match self.family { Family::Courier => s.chars().count() as f64 * 0.6 * self.size, Family::Helvetica => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 1.04 } else { 1.0 }, @@ -132,6 +134,12 @@ impl Face { } } +// Bytes of text measured on this thread, for tests that bound layout work. +#[cfg(test)] +thread_local! { + pub(crate) static MEASURED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Default line height: XFA's font leading for the standard faces. pub fn line_height(size: f64) -> f64 { size * 1.15 @@ -233,11 +241,16 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn lines.push((done, p.h_align)); } if ink > avail - indent(line.first) && line.words.is_empty() { - // A word longer than a line: break it by character. + // A word longer than a line: break it by character. The piece's advance grows by + // each character's (widths add up), so no prefix is measured again. let mut piece = String::new(); + let mut advance = 0.0; + let mut buf = [0u8; 4]; for ch in w.text.chars() { + let ch_width = w.face.width(ch.encode_utf8(&mut buf)); piece.push(ch); - if w.face.width(&piece) > avail - indent(line.first) && piece.chars().count() > 1 { + advance += ch_width; + if advance > avail - indent(line.first) && piece.len() > ch.len_utf8() { piece.pop(); let width = w.face.width(&piece); line.words.push(Word { text: std::mem::take(&mut piece), face: w.face.clone(), width }); @@ -246,6 +259,7 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false }); lines.push((done, p.h_align)); piece.push(ch); + advance = ch_width; } } if !piece.is_empty() { From 3412437191ab4cbd18bd2b519d94fada7f42dfa1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:21:00 +0000 Subject: [PATCH 126/517] fix(xfa): bound the embedded field text one text block expands Rich text resolved every embedded-field reference to a fresh copy of the field's value, so a template repeating one reference to a long value many times laid out a copy per reference. One text block expands at most as many bytes of embedded values as a text of its own may hold; past that the values are cut. --- crates/xfa/src/tests.rs | 14 ++++++++++++++ crates/xfa/src/text.rs | 25 +++++++++++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 17e286d6c5..31a3684814 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2670,3 +2670,17 @@ fn a_long_word_breaks_in_linear_work() { assert_eq!(b.spans.iter().map(|s| s.text.len()).sum::(), 20_000); assert!(measured < 200_000, "{measured} bytes measured for a 20000-byte word"); } + +/// Rich text that embeds one long field value many times lays out a bounded amount of it. +#[test] +fn embedded_field_values_expand_to_a_bounded_amount_of_text() { + let face = text::Face::of(&Font { typeface: "Arial".into(), size: 10.0, ..Font::default() }); + let value = "word ".repeat(3_300); + let run = Run { embed: Some("f".into()), ..Run::default() }; + let rich = RichText { paragraphs: vec![Paragraph { runs: vec![run; 100], h_align: None }] }; + let b = text::layout(&rich, Rect::new(0.0, 0.0, 500.0, 0.0), &Para::default(), &face, &|_| value.clone()); + let kept: usize = b.spans.iter().map(|s| s.text.len()).sum(); + assert!(kept > 0); + assert!(kept <= text::MAX_EMBEDDED, "{kept} bytes of embedded text laid out"); + assert!(text::natural_width(&rich, &Para::default(), &face, &|_| value.clone()) <= face.width(&value) * 70.0); +} diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index fc09d355e7..63de872444 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -177,13 +177,27 @@ fn ink_width(w: &Word) -> f64 { if ink.len() == w.text.len() { w.width } else { w.face.width(ink) } } +/// Most bytes of embedded field values one text block shows, as much as a text of its own +/// holds: a value embedded over and over is expanded only so far. +pub(crate) const MAX_EMBEDDED: usize = 1 << 20; + /// Break the runs of a paragraph into words (spaces stay attached to the word before them). -fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str) -> String) -> Vec { +/// Embedded values take from `embed_left` bytes and are cut when it runs out. +fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str) -> String, embed_left: &mut usize) -> Vec { let mut out = Vec::new(); for run in runs { let face = base.with_run(run); let text: String = match &run.embed { - Some(id) => embed(id), + Some(_) if *embed_left == 0 => String::new(), + Some(id) => { + let mut v = embed(id); + if v.len() > *embed_left { + let end = v.char_indices().map(|(i, _)| i).take_while(|i| *i <= *embed_left).last().unwrap_or(0); + v.truncate(end); + } + *embed_left = embed_left.saturating_sub(v.len()); + v + } None => run .text .chars() @@ -224,8 +238,9 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn let avail = (rect.w - para.margin_left - para.margin_right).max(1.0); let mut lines: Vec<(Line, Option)> = Vec::new(); let mut total = 0.0; + let mut embed_left = MAX_EMBEDDED; for (pi, p) in rich.paragraphs.iter().enumerate() { - let ws = words(&p.runs, base, embed); + let ws = words(&p.runs, base, embed, &mut embed_left); let mut line = Line { words: Vec::new(), width: 0.0, size: base.size, first: true }; let mut first_in_para = true; let indent = |first: bool| if first { para.text_indent.max(0.0) } else { 0.0 }; @@ -336,7 +351,9 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn /// margins and first-line indent): how wide a box without an explicit width grows. pub fn natural_width(rich: &RichText, para: &Para, base: &Face, embed: &dyn Fn(&str) -> String) -> f64 { let indent = para.text_indent.max(0.0); - let widest = rich.paragraphs.iter().map(|p| words(&p.runs, base, embed).iter().map(|w| w.width).sum::()).fold(0.0, f64::max); + let mut embed_left = MAX_EMBEDDED; + let widest = + rich.paragraphs.iter().map(|p| words(&p.runs, base, embed, &mut embed_left).iter().map(|w| w.width).sum::()).fold(0.0, f64::max); (para.margin_left + indent + widest + para.margin_right).max(0.0) } From 1dbfa251000956dc968680ebf7f53a47d7f932f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:22:52 +0000 Subject: [PATCH 127/517] fix(xfa): bound the script text the live form's events hold Every instance of a repeated subform records its own copy of each script, and only the number of events was capped. A long script inside nested repeating subforms was copied once per instance before any script ran. The events hold at most as much script text as the XFA packets may; past that the live form is marked truncated, as it is past the event cap. --- crates/xfa/src/script.rs | 52 +++++++++++++++++++--------------------- crates/xfa/src/tests.rs | 27 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index d39d53e37a..cb555dc81d 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -15,6 +15,9 @@ pub const OVERRIDES_KEY: &[u8] = b"PCXfaOverrides"; const MAX_DEPTH: usize = 64; const MAX_EVENTS: usize = 20_000; +/// Most bytes of script the events hold together: each instance of a repeated subform holds its +/// own copy, so a long script repeated many times stops here (as much as the XFA packets hold). +pub(crate) const MAX_EVENT_SCRIPT_BYTES: usize = 64 << 20; /// Most objects of the live form built (the scripting engine's snapshot holds as many): /// nested repeating subforms multiply, so the walk stops here and says so. pub const MAX_FORM_NODES: usize = 200_000; @@ -67,8 +70,8 @@ pub struct LiveForm { pub root: FormNode, /// Every scripted event, in document order. pub events: Vec, - /// The form was larger than [`MAX_FORM_NODES`] objects (or [`MAX_EVENTS`] events): what - /// is past that was left out. + /// The form was larger than [`MAX_FORM_NODES`] objects (or [`MAX_EVENTS`] events, or + /// [`MAX_EVENT_SCRIPT_BYTES`] of their scripts): what is past that was left out. pub truncated: bool, } @@ -172,6 +175,8 @@ struct Walker<'a> { data: Option<&'a DataNode>, ov: &'a Overrides, events: Vec, + /// Bytes of script the events hold. + script_bytes: usize, /// Objects built so far. nodes: usize, truncated: bool, @@ -190,43 +195,34 @@ impl Walker<'_> { /// Record the scripts of the object at `som` as events: each non-empty event script, then its /// calculate and validate scripts (the validate one with `message`). Once [`MAX_EVENTS`] events - /// are recorded, nothing more is and the form is marked truncated. + /// or [`MAX_EVENT_SCRIPT_BYTES`] of their scripts are recorded, nothing more is and the form is + /// marked truncated. fn push_events(&mut self, som: &str, scripts: &[Script], calculate: Option<&Script>, validate: Option<&Script>, message: Option<&str>) { if self.events.len() >= MAX_EVENTS { self.truncated = true; return; } - for s in scripts { + let all = scripts + .iter() + .map(|s| (s, s.activity.as_str(), None)) + .chain(calculate.map(|c| (c, "calculate", None))) + .chain(validate.map(|v| (v, "validate", message))); + for (s, activity, message) in all { if s.text.trim().is_empty() { continue; } + match self.script_bytes.checked_add(s.text.len()).filter(|n| *n <= MAX_EVENT_SCRIPT_BYTES) { + Some(n) => self.script_bytes = n, + None => { + self.truncated = true; + return; + } + } self.events.push(ScriptEvent { som: som.to_string(), - activity: s.activity.clone(), + activity: activity.to_string(), script: s.text.clone(), formcalc: s.formcalc, - message: None, - }); - } - if let Some(c) = calculate - && !c.text.trim().is_empty() - { - self.events.push(ScriptEvent { - som: som.to_string(), - activity: "calculate".into(), - script: c.text.clone(), - formcalc: c.formcalc, - message: None, - }); - } - if let Some(v) = validate - && !v.text.trim().is_empty() - { - self.events.push(ScriptEvent { - som: som.to_string(), - activity: "validate".into(), - script: v.text.clone(), - formcalc: v.formcalc, message: message.map(str::to_string), }); } @@ -410,7 +406,7 @@ impl Walker<'_> { /// from the template and `ov`) and every scripted event, in document order, within /// [`MAX_FORM_NODES`] objects. pub fn form_tree(tpl: &Template, data: Option<&DataNode>, ov: &Overrides) -> LiveForm { - let mut w = Walker { tpl, data, ov, events: Vec::new(), nodes: 0, truncated: false }; + let mut w = Walker { tpl, data, ov, events: Vec::new(), script_bytes: 0, nodes: 0, truncated: false }; let root = Node::Subform(Box::new(tpl.root.clone())); let mut nodes = w.children(std::slice::from_ref(&root), "", 0); let mut root_node = nodes.pop().unwrap_or_default(); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 31a3684814..a101f219b5 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2684,3 +2684,30 @@ fn embedded_field_values_expand_to_a_bounded_amount_of_text() { assert!(kept <= text::MAX_EMBEDDED, "{kept} bytes of embedded text laid out"); assert!(text::natural_width(&rich, &Para::default(), &face, &|_| value.clone()) <= face.width(&value) * 70.0); } + +/// Repeated subforms each carry their own copy of a long script: the live form holds a bounded +/// amount of script text and says it left the rest out. +#[test] +fn repeated_script_events_hold_a_bounded_amount_of_script() { + let script = format!("var a = 1; //{}", "x".repeat(32 * 1024)); + let xml = format!( + r##" + +"## + ); + let (tpl, _) = parse(&xml).unwrap(); + let live = form_tree(&tpl, None, &Overrides::default()); + assert!(!live.events.is_empty()); + let held: usize = live.events.iter().map(|e| e.script.len()).sum(); + assert!(held <= crate::script::MAX_EVENT_SCRIPT_BYTES, "{held} bytes of script held by {} events", live.events.len()); + assert!(live.truncated, "{held} bytes, {} events", live.events.len()); +} From a07ad60d529dce2c68f355ab84038b1d819f46fa Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:25:19 +0000 Subject: [PATCH 128/517] fix(xfa): queue each added data instance once per pass write_data_ops expanded every instance-count op into one queued node per instance, then deduplicated the pass. A script adding rows one at a time asks for one more instance each time, so n rows queued about n*n/2 nodes before the deduplication. Each op queues only the instances the pass has not queued yet, and a budget bounds the instances queued across passes that removals separate, with a warning past it. --- crates/xfa/src/data.rs | 32 ++++++++++++++++++++++++++++++-- crates/xfa/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 7c2efaf5ed..87db6b9986 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -1516,6 +1516,16 @@ fn element_markup(src: &str, n: roxmltree::Node) -> Option { Some(format!("{}{decls}{}", text.get(..name_end)?, text.get(name_end..)?)) } +/// Most instance nodes one [`write_data_ops`] queues over all its passes (a removal starts a new +/// pass, which asks for the instances again). +const MAX_ENSURED: usize = 200_000; + +// Instance nodes write_data_ops has queued on this thread, for tests that bound its work. +#[cfg(test)] +thread_local! { + pub(crate) static ENSURED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script /// event changed becomes a single new datasets stream. When the packet is already the stream /// `reuse` (written earlier for the same event), that stream is replaced in place instead of @@ -1525,6 +1535,9 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) let mut steps: Vec> = Vec::new(); let mut removes: Vec> = Vec::new(); let mut run: Vec = Vec::new(); + // Instances queued in this run, by parent and name: asking for more only queues the rest. + let mut queued: HashMap<(&DataPath, &str), usize> = HashMap::new(); + let (mut ensured, mut capped) = (0usize, false); let flush = |run: &mut Vec, steps: &mut Vec>, removes: &mut Vec>| { if !run.is_empty() { let mut seen = std::collections::HashSet::new(); @@ -1541,7 +1554,17 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false }); } DataOp::Instances { parent, name, count } => { - for k in 0..(*count).min(MAX_INDEX) { + let done = queued.entry((parent, name.as_str())).or_insert(0); + while *done < (*count).min(MAX_INDEX) { + if ensured >= MAX_ENSURED { + capped = true; + break; + } + ensured += 1; + #[cfg(test)] + ENSURED.with(|n| n.set(n.get() + 1)); + let k = *done; + *done += 1; let mut path = parent.clone(); path.push((name.clone(), k)); run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true }); @@ -1549,6 +1572,7 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) } DataOp::Remove(path) if !path.is_empty() => { flush(&mut run, &mut steps, &mut removes); + queued.clear(); steps.push(Vec::new()); removes.push(Some(path.clone())); } @@ -1564,7 +1588,11 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) None => Op::Values(w), }) .collect(); - rewrite(doc, &plan, reuse) + let mut written = rewrite(doc, &plan, reuse)?; + if capped { + written.warnings.push(format!("XFA data instances not written past {MAX_ENSURED}: the rows were added and removed too often")); + } + Ok(written) } /// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index a101f219b5..9aebee5bf4 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2711,3 +2711,34 @@ fn repeated_script_events_hold_a_bounded_amount_of_script() { assert!(held <= crate::script::MAX_EVENT_SCRIPT_BYTES, "{held} bytes of script held by {} events", live.events.len()); assert!(live.truncated, "{held} bytes, {} events", live.events.len()); } + +/// Rows added one at a time (each op asking for one more instance) queue each instance node +/// once, and removals in between don't let the queue grow without bound. +#[test] +fn added_instances_are_queued_once_each() { + use crate::data::{DataOp, ENSURED, write_data_ops}; + let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap(); + render_into(&mut doc).unwrap(); + let table = som_to_path("form[0].page1[0].table[0]"); + let ops: Vec = (1..=1000).map(|count| DataOp::Instances { parent: table.clone(), name: "row".into(), count }).collect(); + ENSURED.with(|n| n.set(0)); + let r = write_data_ops(&mut doc, &ops, None).unwrap(); + assert!(r.written, "{r:?}"); + assert_eq!(crate::data_of(&doc).unwrap().count(&table, "row"), 1000); + let queued = ENSURED.with(|n| n.get()); + assert!(queued <= 2_000, "{queued} instance nodes queued for 1000 rows"); + // Removals split the ops into separate passes; together they stay within the budget. + let ops: Vec = (0..300) + .flat_map(|_| { + [ + DataOp::Instances { parent: table.clone(), name: "row".into(), count: 1000 }, + DataOp::Remove(som_to_path("form[0].page1[0].table[0].row[999]")), + ] + }) + .collect(); + ENSURED.with(|n| n.set(0)); + let r = write_data_ops(&mut doc, &ops, None).unwrap(); + let queued = ENSURED.with(|n| n.get()); + assert!(queued <= 200_000, "{queued} instance nodes queued"); + assert!(r.warnings.iter().any(|w| w.contains("instances")), "{r:?}"); +} From bac0e610db7a314680c6790929e0e6c23a57a5a5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:25:57 +0000 Subject: [PATCH 129/517] fix(xfa): bound the bytes a data import copies out of its file Converting an imported data file copied each element's and attribute's resolved namespace URI, so a long namespace declared once on the root was copied into every element that inherits it; only the node count was limited. The import counts the names, namespaces, attributes and text it copies against a byte budget of four times the file-size limit, and refuses the file as too large past it. --- crates/xfa/src/data.rs | 64 +++++++++++++++++++++++++++-------------- crates/xfa/src/tests.rs | 16 +++++++++++ 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 87db6b9986..21c12a58b9 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -801,19 +801,52 @@ pub struct ImportNode { /// Deepest data an import keeps. const MAX_IMPORT_DEPTH: usize = MAX_DEPTH; -/// `n` as an [`ImportNode`], counting nodes against `budget`; `None` past the depth or node -/// limits. -fn import_node(n: roxmltree::Node, depth: usize, budget: &mut usize) -> Option { - if depth > MAX_IMPORT_DEPTH || *budget == 0 { - return None; +/// Most bytes of names, namespaces, attributes and text an import copies out of its file: a +/// namespace declared once is copied into every element and attribute that inherits it. +pub(crate) const MAX_IMPORT_BYTES: usize = 4 * MAX_IMPORT; + +/// What an import may still copy: nodes and bytes. +struct ImportBudget { + nodes: usize, + bytes: usize, +} + +impl ImportBudget { + fn new() -> ImportBudget { + ImportBudget { nodes: MAX_NODES as usize, bytes: MAX_IMPORT_BYTES } + } + + fn take_bytes(&mut self, n: usize) -> Result<(), XfaError> { + self.bytes = self + .bytes + .checked_sub(n) + .ok_or_else(|| XfaError::TooLarge(format!("the data file expands to more than {} MB", MAX_IMPORT_BYTES >> 20)))?; + Ok(()) + } +} + +/// `n` as an [`ImportNode`], counting nodes and copied bytes against `budget`; `TooLarge` past +/// the depth or either budget. +fn import_node(n: roxmltree::Node, depth: usize, budget: &mut ImportBudget) -> Result { + if depth > MAX_IMPORT_DEPTH { + return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); + } + budget.nodes = budget.nodes.checked_sub(1).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?; + let name = n.tag_name().name(); + let ns = n.tag_name().namespace(); + budget.take_bytes(name.len().saturating_add(ns.map_or(0, str::len)))?; + let mut attrs = Vec::new(); + for a in n.attributes().filter(|a| a.name() != "xmlns" && a.namespace() != Some("http://www.w3.org/2000/xmlns/")) { + budget.take_bytes(a.namespace().map_or(0, str::len).saturating_add(a.name().len()).saturating_add(a.value().len()))?; + attrs.push((a.namespace().map(str::to_string), a.name().to_string(), a.value().to_string())); } - *budget -= 1; let mut children = Vec::new(); let mut text = String::new(); for c in n.children() { if c.is_element() { children.push(import_node(c, depth + 1, budget)?); } else if let Some(t) = c.text() { + budget.take_bytes(t.len())?; text.push_str(t); } } @@ -821,17 +854,7 @@ fn import_node(n: roxmltree::Node, depth: usize, budget: &mut usize) -> Option) -> Result; this form's data root is <{want}>"))); } - let mut budget = MAX_NODES as usize; - let node = import_node(root, 0, &mut budget).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?; + let node = import_node(root, 0, &mut ImportBudget::new())?; return Ok(vec![node]); } }; - let mut budget = MAX_NODES as usize; + let mut budget = ImportBudget::new(); let mut out = Vec::new(); for c in data.children().filter(|c| c.is_element()) { - out.push(import_node(c, 1, &mut budget).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?); + out.push(import_node(c, 1, &mut budget)?); } Ok(out) } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 9aebee5bf4..bf8872ad58 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2742,3 +2742,19 @@ fn added_instances_are_queued_once_each() { assert!(queued <= 200_000, "{queued} instance nodes queued"); assert!(r.warnings.iter().any(|w| w.contains("instances")), "{r:?}"); } + +/// A namespace declared once and inherited by many imported elements is not copied into each +/// past the import's byte budget. +#[test] +fn an_inherited_namespace_does_not_multiply_an_import() { + use crate::data::parse_data_file; + let uri = format!("urn:{}", "n".repeat(70_000)); + let xml = format!("{}", "".repeat(1_000)); + fn ns_bytes(nodes: &[crate::data::ImportNode]) -> usize { + nodes.iter().map(|n| n.ns.as_ref().map_or(0, String::len) + ns_bytes(&n.children)).sum() + } + match parse_data_file(&xml, None) { + Ok(nodes) => assert!(ns_bytes(&nodes) <= crate::data::MAX_IMPORT_BYTES, "{} bytes of namespaces copied", ns_bytes(&nodes)), + Err(e) => assert!(matches!(e, XfaError::TooLarge(_)), "{e:?}"), + } +} From ee333376277ee7f78d17663bd11f081bae4a5df5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:26:28 +0000 Subject: [PATCH 130/517] fix(xfa): bound the name bytes of data nodes a write creates Writing a value at a high index creates every missing sibling before it, each with its own copy of the name. The node budget counted siblings but not their names, so a long name with a high index made thousands of copies of it. The write's budget also counts the bytes of the names it creates; a path past it is skipped with the existing warning. --- crates/xfa/src/data.rs | 37 ++++++++++++++++++++++++++----------- crates/xfa/src/tests.rs | 13 +++++++++++++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 21c12a58b9..2b80d8b96a 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -519,6 +519,21 @@ fn escape(s: &str) -> (String, bool) { const MAX_NEW_NODES: usize = 20_000; /// Highest same-name sibling index a write follows. const MAX_INDEX: usize = 10_000; +/// Most bytes of names the nodes one write creates hold together: every sibling an index +/// skips over gets its own copy of the name. +const MAX_NEW_NAME_BYTES: usize = 4 << 20; + +/// What one write may still create: nodes and bytes of their names. +struct NewNodes { + nodes: usize, + name_bytes: usize, +} + +impl NewNodes { + fn new() -> NewNodes { + NewNodes { nodes: MAX_NEW_NODES, name_bytes: MAX_NEW_NAME_BYTES } + } +} /// A data tree being built for writing. #[derive(Default)] @@ -533,8 +548,8 @@ struct Build { impl Build { /// The node at `path` below this one, created as needed. `None` when the path is deeper /// than [`MAX_DEPTH`], an index is above [`MAX_INDEX`], or creating it would spend more - /// than `budget` new nodes. - fn at(&mut self, path: &[(String, usize)], budget: &mut usize) -> Option<&mut Build> { + /// new nodes or name bytes than `budget` holds. + fn at(&mut self, path: &[(String, usize)], budget: &mut NewNodes) -> Option<&mut Build> { if path.len() > MAX_DEPTH { return None; } @@ -547,15 +562,15 @@ impl Build { let have = node.by_name.get(&name).map_or(0, Vec::len); if *idx >= have { let need = idx - have + 1; - if need > *budget { + let bytes = need.saturating_mul(name.len()); + if need > budget.nodes || bytes > budget.name_bytes { return None; } - *budget -= need; - for _ in 0..need { - let pos = node.children.len(); - node.by_name.entry(name.clone()).or_default().push(pos); - node.children.push(Build { name: name.clone(), ..Default::default() }); - } + budget.nodes -= need; + budget.name_bytes -= bytes; + let start = node.children.len(); + node.children.extend((0..need).map(|_| Build { name: name.clone(), ..Default::default() })); + node.by_name.entry(name.clone()).or_default().extend(start..start + need); } let pos = *node.by_name.get(&name)?.get(*idx)?; node = node.children.get_mut(pos)?; @@ -675,7 +690,7 @@ const CONTROL: &str = "Characters XML can't hold were left out of the XFA data o /// Fresh data for `wants`: the children of an `xfa:data` element. fn build_children(wants: &[Want], warnings: &mut Vec) -> String { let mut root = Build::default(); - let mut budget = MAX_NEW_NODES; + let mut budget = NewNodes::new(); for w in wants { match root.at(&w.path, &mut budget) { Some(node) => node.text = w.text.clone(), @@ -1095,7 +1110,7 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve let tree = data.document(); // Nodes to add, per existing element they go into. let mut added: Vec<(roxmltree::NodeId, Build)> = Vec::new(); - let mut budget = MAX_NEW_NODES; + let mut budget = NewNodes::new(); for w in wants { let (escaped, dropped) = escape(&w.text); let mut node = data; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index bf8872ad58..a4ebacafdb 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2758,3 +2758,16 @@ fn an_inherited_namespace_does_not_multiply_an_import() { Err(e) => assert!(matches!(e, XfaError::TooLarge(_)), "{e:?}"), } } + +/// A long data name with a high index is not copied into every sibling created before it. +#[test] +fn long_names_repeated_by_an_index_are_capped() { + let doc = Document::open(Arc::new(static_shell(""))).unwrap(); + let fields = vec![FieldDatum { + obj: pdfcraft_cos::ObjRef::new(1000, 0), + name: format!("form1[0].{}[9999]", "n".repeat(2_000)), + data: FieldData::Text("v".into()), + }]; + let data = build_data(&doc, &fields); + assert!(data.len() < 2_000_000, "{} bytes of data built", data.len()); +} From 4aad36cf199aa7d5dd568a695dd810e6dc665a43 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:05:41 +0000 Subject: [PATCH 131/517] fix(create): refuse an indexed colour space whose base is indexed before following it Image export parsed an Indexed colour space's base before checking whether that base was itself Indexed. A base that refers back to the same array recursed until the stack overflowed. The base is now checked first; since Indexed is the only colour space that recurses, this also ends any cycle. --- crates/create/src/extract.rs | 11 +++++++++-- crates/create/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 768da4d1f1..bd5ab5271c 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -383,10 +383,17 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result { Some(b"CalGray") => Ok(Space::Gray), Some(b"CalRGB") => Ok(Space::Rgb), Some(b"Indexed" | b"I") => { - let base = space(doc, a.get(1))?; - if matches!(base, Space::Indexed(..)) { + // Checked before recursing: Indexed is the only space that recurses, so refusing + // an Indexed base here also ends a cyclic base reference. + let nested = match a.get(1).map(|b| doc.resolve(b)).as_deref() { + Some(Object::Array(b)) => matches!(b.first().and_then(Object::as_name), Some(b"Indexed" | b"I")), + Some(Object::Name(n)) => matches!(n.as_slice(), b"Indexed" | b"I"), + _ => false, + }; + if nested { return Err("nested indexed colour spaces aren't valid".into()); } + let base = space(doc, a.get(1))?; let hival = match a.get(2).map(|value| doc.resolve(value)).as_deref() { Some(Object::Int(value)) if (0..=255).contains(value) => *value as u8, _ => return Err("an indexed colour space with an invalid hival".into()), diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 6356105699..a214977675 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -1181,3 +1181,19 @@ fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() { let img = image_of(&doc, 1); assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20))); } + +#[test] +fn a_cyclic_indexed_colour_space_is_refused() { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + // 5 0 obj [/Indexed 5 0 R 255 <00>]: the base is the indexed space itself. + let cs = doc.add(Object::Null); + doc.set(cs, Object::Array(vec![Object::name("Indexed"), Object::Ref(cs), Object::Int(255), Object::String(PdfString::literal(vec![0]))])); + with_image(&mut doc, |_| { + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"ColorSpace".to_vec(), Object::Ref(cs)); + Stream::from_raw(dict, vec![0]) + }); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty()); + assert_eq!(out.skipped.len(), 1, "{:?}", out.skipped); +} From 78a562bc081b8b2afad687e2399071c003ae9a19 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:08:32 +0000 Subject: [PATCH 132/517] fix(optimize): bound a merged fragment image's height before sizing its buffer The fragment merge multiplied the merged width by a height cast from a float without checking either fit. A tiny fragment rectangle gives a huge height: the multiplication overflowed (a panic in debug builds, a wrap past the pixel limit in release builds) and the u32 cast saturated, sizing an enormous allocation. The height must now fit u32 before the pixel limit is checked, and the buffer size uses checked multiplication. --- crates/optimize/src/fragments.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index b5967eee09..caf617f717 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -143,12 +143,16 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, u32, u3 // Pixel rows per unit of height: the finest fragment's. let density = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max); let height = ((top - bottom) * density).round(); - if !(height.is_finite() && height >= 1.0) || (width as u64) * (height as u64) > MAX_PIXELS { + if !(height.is_finite() && height >= 1.0 && height <= f64::from(u32::MAX)) { return None; } + // In range and whole, so the cast is exact. let height = height as u32; - let row = width as usize * n; - let mut out = vec![255u8; row * height as usize]; + if u64::from(width) * u64::from(height) > MAX_PIXELS { + return None; + } + let row = (width as usize).checked_mul(n)?; + let mut out = vec![255u8; row.checked_mul(height as usize)?]; for f in run { let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height); let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height); @@ -297,3 +301,21 @@ pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, pro } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_merged_height_beyond_u32_is_refused_before_allocating() { + // Two fragments, one pixel row each, the top one 2^-48 high: the merged image would be + // 2^16 × 2^48 pixels, whose product wraps to 0 in u64. + let image = ObjRef { num: 1, generation: 0 }; + let edge = 1.0 - (-48f64).exp2(); + let run = [ + Fragment { image, rect: [0.0, edge, 1.0, 1.0], width: 1 << 16, height: 1 }, + Fragment { image, rect: [0.0, 0.0, 1.0, edge], width: 1 << 16, height: 1 }, + ]; + assert!(merge(&Document::new_empty(), &run, 3).is_none()); + } +} From f0e3880c35327859f82228caaa8219d86b8955c0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:12:52 +0000 Subject: [PATCH 133/517] fix(xfdf): never let a same-named imported comment replace a form widget Comment import replaces an existing annotation with the same /NM in place. The check for annotations it must keep covered locked comments and redaction marks but not widgets or field dictionaries, so an imported comment could overwrite a field's widget object, setting its value and validation entries without the form value setter while the AcroForm field list still pointed at it. Widgets and field dictionaries are now kept, and the imported comment is added beside them under no name. --- crates/xfdf/src/lib.rs | 11 ++++++----- crates/xfdf/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index ab258990e2..1e037b6795 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1017,10 +1017,11 @@ const REDACT_UNSEEN: i64 = 1 | 2 | 32; /// `/F` Locked (ISO 32000-2 Table 167): the user protected this annotation from change. const LOCKED: i64 = 128; -/// An existing annotation an import must never replace, move or unlock: one the user locked, or a -/// redaction mark (replacing it would drop the region the user marked to remove). +/// An existing annotation an import must never replace, move or unlock: one the user locked, a +/// redaction mark (replacing it would drop the region the user marked to remove), or a form +/// widget or field (replacing it would set the field's value and policy around the form setter). fn import_protected(x: &Dict) -> bool { - x.int(b"F").unwrap_or(0) & LOCKED != 0 || x.name(b"Subtype") == Some(b"Redact".as_slice()) + x.int(b"F").unwrap_or(0) & LOCKED != 0 || matches!(x.name(b"Subtype"), Some(b"Redact" | b"Widget")) || x.contains(b"FT") } fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { @@ -1042,8 +1043,8 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result = Vec::new(); - // Never let a same-name import replace a locked comment or a redaction mark: keep it, and add - // the imported dict beside it under no name of its own. + // Never let a same-name import replace a protected annotation (`import_protected`): keep it, + // and add the imported dict beside it under no name of its own. let mut clash = false; for a in annots { let existing = a.as_ref().and_then(|r| doc.get(r).as_dict().cloned()); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 6c9824a245..57155e4b7a 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1660,3 +1660,22 @@ fn tabular_import_keeps_tsv_quotes_literal_and_csv_value_tabs() { import(&mut doc, b"Name,Other\r\n\"Ada\tLovelace\",other field\r\n").unwrap(); assert_eq!(fields(&doc).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["Ada\tLovelace"]); } + +#[test] +fn a_same_named_comment_import_never_replaces_a_form_widget() { + let mut doc = blank(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let widget = doc + .resolve(page.dict.get(b"Annots").unwrap()) + .as_array() + .unwrap() + .iter() + .filter_map(Object::as_ref) + .find(|r| doc.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget".as_slice()))) + .unwrap(); + doc.update_dict(widget, |d| d.set(b"NM".to_vec(), PdfString::text("w1"))).unwrap(); + let before = doc.get(widget); + let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Text /NM (w1) /Page 0 /Rect [0 0 1 1] /FT /Tx /T (Name) /V (forged) >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + assert_eq!(import(&mut doc, fdf).unwrap().comments, 1); + assert_eq!(doc.get(widget), before, "the widget was overwritten by an imported comment"); +} From d266222339adf378f785c98fdd3ae65eb8831867 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:22:02 +0000 Subject: [PATCH 134/517] fix(xfdf): draw every imported redaction mark's review outline, opaque A redaction mark's outline is drawn from its /QuadPoints, but applying the mark falls back to its /Rect when it has none. An imported mark with only a /Rect, or with quads the outline can't draw, therefore had no visible outline while still removing content when applied, and an imported opacity of 0 hid the outline too. Imported marks now get quads from their /Rect when their own are unusable (or none, so they remove nothing), and their /CA and /ca are dropped. --- crates/xfdf/src/lib.rs | 21 +++++++++++++++++++++ crates/xfdf/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 1e037b6795..80c37b5e86 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1032,6 +1032,27 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { + let (l, r, b, t) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1)); + d.set(b"QuadPoints".to_vec(), arr(&[l, t, r, t, l, b, r, b])); + } + _ => { + d.remove(b"QuadPoints"); + } + } + } } let nm = d.get(b"NM").and_then(|n| n.as_string().map(|s| s.to_text())); let annots: Vec = doc diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 57155e4b7a..7951bef4dd 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1679,3 +1679,26 @@ fn a_same_named_comment_import_never_replaces_a_form_widget() { assert_eq!(import(&mut doc, fdf).unwrap().comments, 1); assert_eq!(doc.get(widget), before, "the widget was overwritten by an imported comment"); } + +#[test] +fn an_imported_rect_only_or_transparent_redaction_mark_is_drawn_for_review() { + let xfdf = br#""#; + let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Redact /NM (r1) /Page 0 /Rect [60 600 560 620] /CA 0 /ca 0 >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + for data in [xfdf.as_slice(), fdf.as_slice()] { + let mut doc = blank(); + assert_eq!(import(&mut doc, data).unwrap().comments, 1); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let mark = doc + .resolve(page.dict.get(b"Annots").unwrap()) + .as_array() + .unwrap() + .iter() + .filter_map(|a| a.as_ref().and_then(|r| doc.get(r).as_dict().cloned())) + .find(|d| d.name(b"Subtype") == Some(b"Redact".as_slice())) + .unwrap(); + assert_eq!(nums_of(&doc, mark.get(b"QuadPoints")), vec![60.0, 620.0, 560.0, 620.0, 60.0, 600.0, 560.0, 600.0]); + let ap = mark.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()); + assert!(ap.is_some_and(|ap| ap.get(b"N").is_some()), "the mark has no review appearance: {mark:?}"); + assert!(mark.get(b"CA").is_none() && mark.get(b"ca").is_none(), "the mark can be drawn transparent: {mark:?}"); + } +} From 8a8dab6c00ad27e9d3e1b66e42442c1fbd930ba7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:40 +0000 Subject: [PATCH 135/517] fix(xfdf): keep merged spreadsheet cells from starting a formula Merge Data Files into Spreadsheet writes field names and values from other people's files as CSV cells, escaping only the delimiters. A cell starting with =, +, - or @ is evaluated as a formula when the CSV is opened in a spreadsheet program. Such cells, after any leading whitespace, are now written with a leading apostrophe; finite numbers such as -12.5 are left as they are. --- crates/xfdf/src/lib.rs | 18 +++++++++++++++++- crates/xfdf/src/tests.rs | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 80c37b5e86..1eac1f96c5 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1287,6 +1287,10 @@ pub fn data_values(bytes: &[u8]) -> Result)>, DataError /// Merge Data Files into Spreadsheet: one CSV row per file, one column per field name (in the /// order they first appear). +/// +/// The names and values come from other people's files, and spreadsheet programs evaluate a cell +/// that starts with `=`, `+`, `-` or `@` (after any leading whitespace) as a formula. Such a cell +/// is written with a leading `'`, which keeps it text; a plain number such as `-12.5` is not. pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { let mut columns: Vec<&str> = Vec::new(); for f in files { @@ -1296,7 +1300,19 @@ pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { } } } - let q = |s: &str| if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let literal = |s: &str| { + let t = s.trim_start(); + let formula = match t.chars().next() { + Some('=' | '@') => true, + Some('+' | '-') => !t.parse::().is_ok_and(f64::is_finite), + _ => false, + }; + if formula { format!("'{s}") } else { s.to_string() } + }; + let q = |s: &str| { + let s = literal(s); + if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s } + }; let mut out = columns.iter().map(|c| q(c)).collect::>().join(","); out.push('\n'); for f in files { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 7951bef4dd..3e6dc58a5d 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1702,3 +1702,18 @@ fn an_imported_rect_only_or_transparent_redaction_mark_is_drawn_for_review() { assert!(mark.get(b"CA").is_none() && mark.get(b"ca").is_none(), "the mark can be drawn transparent: {mark:?}"); } } + +#[test] +fn merged_spreadsheet_cells_never_start_a_formula() { + let row = vec![ + ("=HYPERLINK(\"http://x\")".to_string(), vec!["@SUM(A1)".to_string()]), + ("Total".to_string(), vec!["-2+3".to_string()]), + ("Plus".to_string(), vec!["\t+1+cmd".to_string()]), + ("Balance".to_string(), vec!["-12.5".to_string()]), + ("\tName".to_string(), vec!["Ada".to_string()]), + ]; + let csv = merge_csv(&[row]); + let lines: Vec<&str> = csv.lines().collect(); + assert_eq!(lines[0], "\"'=HYPERLINK(\"\"http://x\"\")\",Total,Plus,Balance,\"\tName\""); + assert_eq!(lines[1], "'@SUM(A1),'-2+3,\"'\t+1+cmd\",-12.5,Ada"); +} From 63f7932d06e19b1b9f9a1cb4baaab3550578bb4a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:00:43 +0000 Subject: [PATCH 136/517] fix(xfdf): fall back to the /Rect when an imported redaction mark has a zero-area quad A quad whose corners all lie on one line (or on one point) encloses nothing, yet such /QuadPoints counted as drawable, replaced the mark's /Rect and left an imported redaction that covers nothing. Any quad without area now makes the mark's quads unusable, so its /Rect is used instead. --- crates/xfdf/src/lib.rs | 22 ++++++++++++++++------ crates/xfdf/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 1eac1f96c5..9582298b34 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -246,6 +246,14 @@ fn nums_of(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() } +/// Whether a quad's four corners enclose any area: some three of them are not collinear. +fn quad_has_area(q: &[f64; 8]) -> bool { + let [ax, ay, bx, by, cx, cy, dx, dy] = *q; + let cross = |(px, py): (f64, f64), (qx, qy): (f64, f64), (rx, ry): (f64, f64)| (qx - px) * (ry - py) - (qy - py) * (rx - px); + let (a, b, c, d) = ((ax, ay), (bx, by), (cx, cy), (dx, dy)); + [cross(a, b, c), cross(a, b, d), cross(a, c, d), cross(b, c, d)].iter().any(|v| v.abs() > 0.0) +} + fn text_of(doc: &Document, d: &Dict, k: &[u8]) -> Option { d.get(k).and_then(|o| match &*doc.resolve(o) { Object::String(s) => Some(s.to_text()), @@ -1036,13 +1044,15 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result = q.iter().filter_map(Object::as_f64).filter(|v| v.is_finite()).collect(); + let (quads, rest) = v.as_chunks::<8>(); + v.len() == q.len() && rest.is_empty() && !quads.is_empty() && quads.iter().all(quad_has_area) + }); if !quads_drawable { - // Quads the outline can't draw are replaced by the /Rect's, or dropped when there is - // no usable /Rect either; such a mark then removes nothing. + // Quads the outline can't draw, or any quad that encloses no area, are replaced by the + // /Rect's, or dropped when there is no usable /Rect either; such a mark then removes + // nothing. match nums_of(doc, d.get(b"Rect"))[..] { [x0, y0, x1, y1] if [x0, y0, x1, y1].iter().all(|v| v.is_finite()) => { let (l, r, b, t) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1)); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 3e6dc58a5d..de880fe664 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1717,3 +1717,37 @@ fn merged_spreadsheet_cells_never_start_a_formula() { assert_eq!(lines[0], "\"'=HYPERLINK(\"\"http://x\"\")\",Total,Plus,Balance,\"\tName\""); assert_eq!(lines[1], "'@SUM(A1),'-2+3,\"'\t+1+cmd\",-12.5,Ada"); } + +fn imported_redact_mark(data: &[u8]) -> Dict { + let mut doc = blank(); + assert_eq!(import(&mut doc, data).unwrap().comments, 1); + let page = pdfcraft_model::pages(&doc)[0].clone(); + doc.resolve(page.dict.get(b"Annots").unwrap()) + .as_array() + .unwrap() + .iter() + .filter_map(|a| a.as_ref().and_then(|r| doc.get(r).as_dict().cloned())) + .find(|d| d.name(b"Subtype") == Some(b"Redact".as_slice())) + .unwrap() +} + +#[test] +fn an_imported_redaction_mark_with_zero_area_quads_falls_back_to_its_rect() { + let rect_quads = vec![60.0, 620.0, 560.0, 620.0, 60.0, 600.0, 560.0, 600.0]; + let fdf = |quads: &str| { + format!( + "%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Redact /NM (r1) /Page 0 /Rect [60 600 560 620] /QuadPoints [{quads}] >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n" + ) + }; + let cases = [ + fdf("100 100 100 100 100 100 100 100").into_bytes(), + // A good quad beside one that is a line (all four corners collinear). + fdf("60 620 560 620 60 600 560 600 10 10 20 20 30 30 40 40").into_bytes(), + br#""#.to_vec(), + ]; + for data in cases { + let mark = imported_redact_mark(&data); + let quads: Vec = mark.get(b"QuadPoints").and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect(); + assert_eq!(quads, rect_quads, "zero-area quads were kept: {mark:?}"); + } +} From 256097023c57cc0574287d338051c88676b72096 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:01:55 +0000 Subject: [PATCH 137/517] fix(xfdf): use an imported redaction mark's /Rect only when it is four finite numbers The /Rect fallback dropped non-numbers before checking the length, so a malformed /Rect such as [0 0 /Bad 100 100] read as a valid rectangle and gave the mark quads. The array itself must now hold exactly four finite numbers. --- crates/xfdf/src/lib.rs | 13 +++++++++++-- crates/xfdf/src/tests.rs | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 9582298b34..219b0de81b 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -246,6 +246,15 @@ fn nums_of(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() } +/// A `/Rect` of exactly four finite numbers; any other entry makes the whole rectangle unusable. +fn rect_strict(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { + let num = |x: &Object| doc.resolve(x).as_f64().filter(|v| v.is_finite()); + match doc.resolve(o?).as_array()?.as_slice() { + [x0, y0, x1, y1] => Some([num(x0)?, num(y0)?, num(x1)?, num(y1)?]), + _ => None, + } +} + /// Whether a quad's four corners enclose any area: some three of them are not collinear. fn quad_has_area(q: &[f64; 8]) -> bool { let [ax, ay, bx, by, cx, cy, dx, dy] = *q; @@ -1053,8 +1062,8 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { + match rect_strict(doc, d.get(b"Rect")) { + Some([x0, y0, x1, y1]) => { let (l, r, b, t) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1)); d.set(b"QuadPoints".to_vec(), arr(&[l, t, r, t, l, b, r, b])); } diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index de880fe664..c98fab06ac 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1751,3 +1751,10 @@ fn an_imported_redaction_mark_with_zero_area_quads_falls_back_to_its_rect() { assert_eq!(quads, rect_quads, "zero-area quads were kept: {mark:?}"); } } + +#[test] +fn an_imported_redaction_rect_with_a_non_number_is_not_used() { + let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Redact /NM (r1) /Page 0 /Rect [0 0 /Bad 100 100] >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + let mark = imported_redact_mark(fdf); + assert!(mark.get(b"QuadPoints").is_none(), "quads were drawn from a malformed /Rect: {mark:?}"); +} From f1c56ef86534ec182cde58cc827a700e0a693f51 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:45:44 +0000 Subject: [PATCH 138/517] fix(redact): count a name tree's shared nodes once The hidden-information count followed every /Kids entry of the attachment and document-script name trees with only a depth limit, so a node listed twice by each level was read once per path: a few hundred bytes made the scan take 2^32 steps. Indirect nodes are now read once each, under a total node budget. --- crates/redact/src/sanitize.rs | 34 ++++++++++++++++++++++++++++------ crates/redact/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 83db13d728..c9c2b182aa 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -87,14 +87,36 @@ fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Option { d.get(key).and_then(|o| doc.resolve(o).as_dict().cloned()) } -/// Entries of a name tree (`/Names` leaves, `/Kids` recursion), bounded against cycles. -fn name_tree_len(doc: &Document, node: &Dict, depth: usize) -> usize { - if depth > 32 { +/// The most name-tree nodes one count reads. +const NAME_TREE_NODE_BUDGET: usize = 100_000; + +/// Entries of a name tree (`/Names` leaves, `/Kids` recursion). Each indirect node is read once, +/// however many `/Kids` arrays list it, so shared or cyclic kids cost one step per node rather +/// than one per path; depth and a total node budget bound the rest. +fn name_tree_len(doc: &Document, node: &Dict) -> usize { + let (mut seen, mut visited) = (std::collections::HashSet::new(), 0); + name_tree_len_seen(doc, node, 0, &mut seen, &mut visited) +} + +fn name_tree_len_seen(doc: &Document, node: &Dict, depth: usize, seen: &mut std::collections::HashSet, visited: &mut usize) -> usize { + *visited += 1; + if depth > 32 || *visited > NAME_TREE_NODE_BUDGET { return 0; } let leaves = node.get(b"Names").and_then(|n| doc.resolve(n).as_array().map(|a| a.len() / 2)).unwrap_or(0); let kids = node.get(b"Kids").and_then(|k| doc.resolve(k).as_array().cloned()).unwrap_or_default(); - leaves + kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).map(|k| name_tree_len(doc, &k, depth + 1)).sum::() + let mut n = leaves; + for k in &kids { + if let Object::Ref(r) = k + && !seen.insert(*r) + { + continue; + } + if let Some(kid) = doc.resolve(k).as_dict() { + n = n.saturating_add(name_tree_len_seen(doc, kid, depth + 1, seen, visited)); + } + } + n } /// The most distinct outline items one count reads, over all levels together. @@ -270,7 +292,7 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { let xmp = usize::from(cat.contains(b"Metadata")) + pages.iter().filter(|p| p.dict.contains(b"Metadata")).count(); info + xmp } - Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t, 0)) + count_sub(b"FileAttachment"), + Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + count_sub(b"FileAttachment"), Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), Hidden::FormFields => pdfcraft_forms::fields(doc).len(), Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), @@ -285,7 +307,7 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { + usize::from(cat.contains(b"AA")) + pages.iter().filter(|p| p.dict.contains(b"AA")).count() + annots.iter().filter(|a| a.contains(b"AA") || (a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A"))).count() - + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t, 0)) + + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) } Hidden::PrivateData => usize::from(cat.contains(b"PieceInfo")) + pages.iter().filter(|p| p.dict.contains(b"PieceInfo")).count(), }; diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 286667937d..2a19014a9c 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2472,3 +2472,25 @@ fn a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_under_a_mark() { assert_eq!(under(&mut doc, 0, &[[45.0, 95.0, 50.0, 110.0]]), 0, "nothing is drawn past LASTARR's 35pt width"); assert_eq!(under(&mut doc, 0, &[[10.0, 95.0, 45.0, 110.0]]), 7, "LASTARR's 7 glyphs, the one the page draws"); } + +/// A name tree whose levels each list the next level twice: a walk that follows every path +/// visits 2^levels nodes. +#[test] +fn a_name_tree_whose_nodes_share_kids_counts_each_entry_once() { + use crate::sanitize::{Hidden, scan}; + let levels = 12; + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 5 0 R >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b"BT ET"), + ]; + for k in 0..levels { + let next = 6 + k; + objs.push(format!("<< /Kids [{next} 0 R {next} 0 R] >>").into_bytes()); + } + objs.push(b"<< /Names [(a.txt) << /Type /Filespec /F (a.txt) >>] >>".to_vec()); + let doc = pdf(objs); + let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); + assert_eq!(counts[&Hidden::Attachments], 1, "one entry, reached through 2^{levels} paths"); +} From ee010fbbe5af7d7efcc8859b976a34121f3225d0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:18 +0000 Subject: [PATCH 139/517] fix(redact): read content strictly before sanitizing or redacting it Page and Form content was decoded tolerantly, keeping what came before a corrupt spot and dropping the rest without saying so. A page whose content broke at its first bytes therefore read as empty: the hidden-text scan counted nothing, the removal was skipped and the original stream, which other viewers still draw, stayed in the file. Content is now decoded strictly; an unreadable page counts as something to remove, so the removal runs and refuses the page, and a Form read only in part is treated like an undecodable one. --- crates/redact/src/interp.rs | 3 ++- crates/redact/src/lib.rs | 4 +++- crates/redact/src/sanitize.rs | 3 +++ crates/redact/src/tests.rs | 20 ++++++++++++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index c9a404b935..a608568f38 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -1201,7 +1201,8 @@ fn xobject( out.doomed.push(r); return Some(None); } - let data = match s.decoded() { + // Strict, like page content: a Form read only in part counts as undecodable. + let data = match s.decoded_strict() { Ok(d) => d, Err(_) if scope.mode == Mode::Apply => { scope.report.forms_removed += 1; diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6df5102afa..364f7cc193 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -231,7 +231,9 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result< let mut data = Vec::new(); for o in &list { match &*doc.resolve(o) { - Object::Stream(s) => data.push(s.decoded().map_err(|_| RedactError::Unreadable(index + 1))?), + // Strict: a tolerant decode keeps what precedes corruption and drops the rest, which + // still draws in viewers that read further; anything not read can't be cleaned. + Object::Stream(s) => data.push(s.decoded_strict().map_err(|_| RedactError::Unreadable(index + 1))?), Object::Null => data.push(Vec::new()), _ => return Err(RedactError::Unreadable(index + 1)), } diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index c9c2b182aa..f4c880e30b 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -205,6 +205,9 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re if write { return Err(RedactError::Unreadable(pi + 1)); } + // Content that can't be read may hide anything: count it so the removal runs and + // refuses the page, instead of a zero count skipping it. + total += 1; continue; }; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 2a19014a9c..a0d68af2d7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2494,3 +2494,23 @@ fn a_name_tree_whose_nodes_share_kids_counts_each_entry_once() { let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); assert_eq!(counts[&Hidden::Attachments], 1, "one entry, reached through 2^{levels} paths"); } + +/// Page content that stops decoding at its start (`z` inside an ASCII85 group) while the rest of +/// the stream draws hidden text. A tolerant decode reads nothing, counts nothing and keeps the +/// stream; sanitizing must refuse instead. +#[test] +fn hidden_text_in_content_that_stops_decoding_is_refused() { + use crate::sanitize::{Hidden, remove_hidden, sanitize}; + let encoded: &[u8] = br"!!z6<#'\7PQ#?0Ha>,+>Y->EZd(j+>PW)+B2ko-rWQ%;FOP6+B3(u78s~>"; + let fresh = || { + let mut doc = one_page(b"", "", vec![stream("/Filter /ASCII85Decode", encoded)]); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Contents".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap(); + doc + }; + let mut doc = fresh(); + let out = remove_hidden(&mut doc, &[Hidden::HiddenText]); + assert!(matches!(out, Err(RedactError::Unreadable(1))), "{out:?}"); + let mut doc = fresh(); + let out = sanitize(&mut doc); + assert!(matches!(out, Err(RedactError::Unreadable(1))), "{out:?}"); +} From 3fe9fe218ecbc4fb6ab2f9eba346115001bf0368 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:38 +0000 Subject: [PATCH 140/517] fix(redact): remove the widgets flattening keeps when removing form fields Removing form fields flattens them and then drops the interactive form, but flattening keeps hidden and no-view widgets (they draw nothing) and widgets with no appearance. The pages still listed them, so their values and their parent fields' values survived the full rewrite. Every widget left after flattening now leaves the pages and the structure tree, inline annotations included. --- crates/redact/src/sanitize.rs | 11 ++++++++--- crates/redact/src/tests.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index f4c880e30b..41ef96cccf 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -341,7 +341,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0 { + let fields = which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0; + if fields { // Fields without appearances get one first, so their values stay visible. for f in pdfcraft_forms::fields(doc) { if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { @@ -354,10 +355,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result bool { let s = a.name(b"Subtype").unwrap_or(b""); (which.contains(&Hidden::Comments) && is_comment(s)) + || (fields && s == b"Widget") || (which.contains(&Hidden::Attachments) && s == b"FileAttachment") || (which.contains(&Hidden::LinksActionsScripts) && s == b"Link") }; @@ -374,7 +377,9 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result Object { + Object::Array(nums.iter().map(|&n| Object::Ref(ObjRef::new(n, 0))).collect()) +} + +/// An interactive form dictionary listing fields `nums`. +fn fields_dict(nums: &[u32]) -> Object { + let mut d = Dict::new(); + d.set(b"Fields".to_vec(), ref_array(nums)); + Object::Dict(d) +} + +/// Flattening keeps hidden widgets (they draw nothing); removing form fields must still take them +/// and their values, and their parent field's value, out of the file. +#[test] +fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = one_page( + b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET", + "", + vec![ + // 7 a hidden terminal field merged with its widget, 8 a parent field, 9 its hidden widget + b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (own) /V (LEAK_WIDGET_VALUE) /F 2 /Rect [10 200 150 220] /AP << /N 10 0 R >> >>".to_vec(), + b"<< /FT /Tx /T (parent) /V (LEAK_PARENT_VALUE) /Kids [9 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /F 32 /Rect [10 230 150 250] /AP << /N 10 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 140 20]", b"0 g"), + ], + ); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[7, 8]))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[7, 9]))).unwrap(); + remove_hidden(&mut doc, &[Hidden::FormFields]).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); +} From 750bbe27f1864bcb42b1cc1745256ae91d6db729 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:47:21 +0000 Subject: [PATCH 141/517] fix(redact): clean the structure tree after removing hidden content Removing hidden text or hidden layers rewrote page and Form content but left the structure tree alone, unlike applying redactions. An element's ActualText, Alt or expansion text still described the removed content, and a marked-content reference into a Form kept the original Form, with the removed text, in the full rewrite. The hidden-content pass now cleans the tags of what each page no longer shows and moves Form references to the rewritten copy, refusing when the tree can't be cleaned completely. --- crates/redact/src/sanitize.rs | 13 ++++++++++++- crates/redact/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 41ef96cccf..fea0449512 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -254,7 +254,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let mut cleaned_page = page.dict.clone(); cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); let (_, page_content) = page_streams(doc, &cleaned_page, pi)?; - let mut res = resources; + let mut res = resources.clone(); if !out.xobjects.is_empty() || !out.replaced.is_empty() { let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); // A full save keeps whatever is still referenced: unlink the originals this page no @@ -270,6 +270,17 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); })?; + // Tags must not describe or point at what the page no longer shows. + let (changed_ids, empty_ids) = crate::tags::touched( + doc, + &resources, + pdfcraft_content::Pieces::join(&data).data(), + pdfcraft_content::Pieces::join(&page_content).data(), + ); + crate::tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + } + for (old, new, changed, empty) in out.tag_changes { + crate::tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; } } // What a page no longer draws must not stay reachable through an inherited or shared diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 4d2cc76017..5c0eece859 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2549,3 +2549,31 @@ fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file() assert_no_secret(&doc, b"LEAK_"); assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); } + +/// Removing hidden text cleans the structure tree as redaction does: the elements describing the +/// removed text lose their descriptions, and a reference into a rewritten Form points at the copy. +#[test] +fn removing_hidden_text_cleans_the_structure_tree() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = one_page( + b"/P <> BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC /Fm Do", + "/XObject << /Fm 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >>", + b"/P <> BDC BT /F1 10 Tf 3 Tr 10 150 Td (FORMSECRET) Tj ET EMC", + ), + b"<< /Type /StructTreeRoot /K 9 0 R >>".to_vec(), + b"<< /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (LEAK_PAGE_TAG) /K 0 >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (Public) /K 1 >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /Alt (LEAK_FORM_TAG) /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >>".to_vec(), + ], + ); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"StructTreeRoot".to_vec(), Object::Ref(ObjRef::new(8, 0)))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"StructParents".to_vec(), Object::Int(0))).unwrap(); + remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText"), "untouched content keeps its tags"); +} From 822179ccf2334418c8a711b993528da31d0977ed Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:51:06 +0000 Subject: [PATCH 142/517] fix(redact): remove hidden data from every object that owns it Metadata, private application data, associated files and additional actions were removed from the catalog and pages only. The same entries on an image or font (XMP), a Form XObject (PieceInfo), a page or annotation (/AF with its embedded file) or a form field (/AA scripts) survived the full rewrite, and the scan did not count them, so removing only such a category reported nothing to remove. A walk of every object reachable from the catalog now counts and strips these entries wherever they are, leaving name maps such as resource dictionaries alone (their keys are names a producer chose), and refuses a graph nested too deeply to walk in full. --- crates/redact/src/lib.rs | 4 + crates/redact/src/sanitize.rs | 193 +++++++++++++++++++++++++++++----- crates/redact/src/tests.rs | 94 +++++++++++++++++ 3 files changed, 265 insertions(+), 26 deletions(-) diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 364f7cc193..968773b91d 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -64,6 +64,10 @@ pub enum RedactError { "the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused" )] Tags, + /// Objects written in place nest deeper than hidden information is followed, so its removal + /// could not be shown complete. + #[error("the document's objects nest too deeply to remove hidden information completely; the operation was refused")] + TooDeep, #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index fea0449512..d9edfbde95 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -15,7 +15,7 @@ use crate::{RedactError, Report, annots_of, listed_annotations, page_streams}; pub enum Hidden { /// Document information (`/Info`) and XMP metadata streams. Metadata, - /// Embedded files and file attachment annotations. + /// Embedded files, associated files (`/AF`) and file attachment annotations. Attachments, /// Comments and markup annotations (with their pop-ups). Comments, @@ -163,6 +163,148 @@ fn outline_len_seen( n } +/// Entries that hold a category's data wherever they appear, not only on the catalog and pages: +/// XMP on images, fonts and Forms, private data on Forms, associated files on pages and +/// annotations, additional actions on fields. +const OWNED: [(Hidden, &[u8]); 4] = + [(Hidden::Metadata, b"Metadata"), (Hidden::PrivateData, b"PieceInfo"), (Hidden::Attachments, b"AF"), (Hidden::LinksActionsScripts, b"AA")]; + +/// Keys whose dictionary values map names a producer chooses to objects (resources, glyph +/// procedures, named destinations, role and class maps, private data by application). A name +/// there may be spelt like an owned entry (a font called `/AF`), so these maps are walked but +/// never stripped. +const NAME_MAPS: [&[u8]; 14] = [ + b"Font", + b"XObject", + b"ExtGState", + b"ColorSpace", + b"Pattern", + b"Shading", + b"Properties", + b"CharProcs", + b"Dests", + b"RoleMap", + b"ClassMap", + b"Colorants", + b"Schema", + b"PieceInfo", +]; + +/// How deep the owner sweep follows dictionaries and arrays written in place. +const MAX_DIRECT_DEPTH: usize = 64; + +/// What a dictionary is to the owner sweep. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Held { + /// An object that may own an entry of [`OWNED`]. + Owner, + /// A [`NAME_MAPS`] map: its keys are names, its values owners. + Map, + /// An appearance dictionary (`/AP`): its dictionary values map appearance states. + Appearances, +} + +impl Held { + fn child(self, key: &[u8]) -> Held { + match self { + Held::Owner if NAME_MAPS.contains(&key) => Held::Map, + Held::Owner if key == b"AP" => Held::Appearances, + Held::Appearances => Held::Map, + _ => Held::Owner, + } + } +} + +/// The objects reachable from the catalog that hold one of `keys` outside a name map, and how +/// many dictionaries hold each key. Each object is read at most once per [`Held`] role, so a +/// shared or cyclic graph costs one step per object. +fn owners(doc: &Document, keys: &[&[u8]]) -> Result<(Vec, Vec<(ObjRef, Held)>), RedactError> { + let mut held = vec![0; keys.len()]; + let mut holders = Vec::new(); + let Some(root) = doc.root() else { return Ok((held, holders)) }; + let mut next = vec![(root, Held::Owner)]; + let mut seen = std::collections::HashSet::new(); + while let Some((r, role)) = next.pop() { + if !seen.insert((r, role)) { + continue; + } + if find_owned(&doc.get(r), role, keys, &mut held, &mut next, 0)? { + holders.push((r, role)); + } + } + Ok((held, holders)) +} + +/// Counts the dictionaries in `o` (and what it holds in place) that hold one of `keys`, and +/// queues the indirect objects it names. Returns whether any key was found. +fn find_owned(o: &Object, role: Held, keys: &[&[u8]], held: &mut [usize], next: &mut Vec<(ObjRef, Held)>, depth: usize) -> Result { + if depth > MAX_DIRECT_DEPTH { + return Err(RedactError::TooDeep); + } + let (d, role) = match o { + Object::Ref(r) => { + next.push((*r, role)); + return Ok(false); + } + Object::Array(a) => { + let mut found = false; + for v in a { + found |= find_owned(v, Held::Owner, keys, held, next, depth + 1)?; + } + return Ok(found); + } + Object::Dict(d) => (d, role), + // A stream's dictionary is never a name map. + Object::Stream(s) => (&s.dict, Held::Owner), + _ => return Ok(false), + }; + let mut found = false; + if role != Held::Map { + for (i, k) in keys.iter().enumerate() { + if d.contains(k) + && let Some(n) = held.get_mut(i) + { + *n = n.saturating_add(1); + found = true; + } + } + } + for (k, v) in d.iter() { + found |= find_owned(v, role.child(k), keys, held, next, depth + 1)?; + } + Ok(found) +} + +/// Removes `keys` from `o` and from what it holds in place, outside name maps. [`owners`] has +/// already refused anything nested past the depth limit. +fn strip_owned(o: &mut Object, role: Held, keys: &[&[u8]], depth: usize) { + if depth > MAX_DIRECT_DEPTH { + return; + } + let (d, role) = match o { + Object::Array(a) => { + for v in a { + strip_owned(v, Held::Owner, keys, depth + 1); + } + return; + } + Object::Dict(d) => (d, role), + Object::Stream(s) => (&mut s.dict, Held::Owner), + _ => return, + }; + if role != Held::Map { + for k in keys { + d.remove(k); + } + } + let names: Vec> = d.iter().filter(|(_, v)| matches!(v, Object::Array(_) | Object::Dict(_))).map(|(k, _)| k.clone()).collect(); + for k in names { + if let Some(v) = d.get_mut(&k) { + strip_owned(v, role.child(&k), keys, depth + 1); + } + } +} + /// A comment as the comments list sees it, less file attachments (removed with Attachments) and /// redaction marks. fn is_comment(subtype: &[u8]) -> bool { @@ -296,6 +438,11 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { let annots: Vec = pages.iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| doc.resolve(&a).as_dict().cloned()).collect(); let count_sub = |s: &[u8]| annots.iter().filter(|a| a.name(b"Subtype") == Some(s)).count(); let names = sub(doc, &cat, b"Names").unwrap_or_default(); + let owned_keys: Vec<&[u8]> = OWNED.iter().map(|o| o.1).collect(); + // A graph nested too deeply to walk counts as holding everything, so the removal runs and + // refuses it. + let held = owners(doc, &owned_keys).map_or_else(|_| vec![1; OWNED.len()], |o| o.0); + let owned = |h: Hidden| OWNED.iter().position(|o| o.0 == h).and_then(|i| held.get(i)).copied().unwrap_or(0); let mut doc2 = doc.clone(); HIDDEN .into_iter() @@ -303,10 +450,13 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { let n = match h { Hidden::Metadata => { let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().map(|d| d.len())).unwrap_or(0); - let xmp = usize::from(cat.contains(b"Metadata")) + pages.iter().filter(|p| p.dict.contains(b"Metadata")).count(); - info + xmp + info + owned(Hidden::Metadata) + } + Hidden::Attachments => { + sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + + count_sub(b"FileAttachment") + + owned(Hidden::Attachments) } - Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + count_sub(b"FileAttachment"), Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), Hidden::FormFields => pdfcraft_forms::fields(doc).len(), Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), @@ -318,12 +468,11 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { Hidden::LinksActionsScripts => { count_sub(b"Link") + usize::from(cat.get(b"OpenAction").is_some_and(|a| doc.resolve(a).as_dict().is_some())) - + usize::from(cat.contains(b"AA")) - + pages.iter().filter(|p| p.dict.contains(b"AA")).count() - + annots.iter().filter(|a| a.contains(b"AA") || (a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A"))).count() + + owned(Hidden::LinksActionsScripts) + + annots.iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count() + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) } - Hidden::PrivateData => usize::from(cat.contains(b"PieceInfo")) + pages.iter().filter(|p| p.dict.contains(b"PieceInfo")).count(), + Hidden::PrivateData => owned(Hidden::PrivateData), }; (h, n) }) @@ -406,24 +555,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result Result { - cat.remove(b"Metadata"); doc.trailer_mut().remove(b"Info"); } Hidden::Attachments => { if let Some(n) = names.as_mut() { n.remove(b"EmbeddedFiles"); } - cat.remove(b"AF"); } Hidden::Bookmarks => { cat.remove(b"Outlines"); @@ -457,14 +592,10 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result { - cat.remove(b"PieceInfo"); - } Hidden::HiddenLayers if !off.is_empty() => { // The off layers leave the configuration (their content is gone already). if let Some(mut oc) = sub(doc, &cat, b"OCProperties") { @@ -494,6 +625,16 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = OWNED.iter().filter(|o| which.contains(&o.0)).map(|o| o.1).collect(); + if !keys.is_empty() { + for (r, role) in owners(doc, &keys)?.1 { + let mut o = (*doc.get(r)).clone(); + strip_owned(&mut o, role, &keys, 0); + doc.set(r, o); + } + } if done.is_empty() { return Err(RedactError::NothingToApply); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 5c0eece859..603fede2c7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2577,3 +2577,97 @@ fn removing_hidden_text_cleans_the_structure_tree() { assert_no_secret(&doc, b"SECRET"); assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText"), "untouched content keeps its tags"); } + +/// One page drawing image /Im (object 7) and form /Fm (object 8), with `extra` objects from 9 on. +fn owners_fixture(image: &str, form: &str, extra: Vec>) -> Document { + let mut objs = vec![ + stream(&format!("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 {image}"), b"\x80"), + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 10 10] {form}"), b"0 0 5 5 re f"), + ]; + objs.extend(extra); + one_page(b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET q 10 0 0 10 0 0 cm /Im Do Q /Fm Do", "/XObject << /Im 7 0 R /Fm 8 0 R >>", objs) +} + +/// Removing one category and saving leaves none of `secret` in the file, and the scan counted it. +fn removes_from_every_owner(mut doc: Document, h: crate::sanitize::Hidden, secret: &[u8]) { + use crate::sanitize::{remove_hidden, scan}; + let counts: std::collections::HashMap<_, usize> = scan(&doc).into_iter().collect(); + assert!(counts[&h] > 0, "{h:?} is counted"); + remove_hidden(&mut doc, &[h]).unwrap(); + assert_no_secret(&doc, secret); + assert!(scan(&reopen(&doc)).into_iter().all(|(k, n)| k != h || n == 0 || h == crate::sanitize::Hidden::Metadata)); +} + +#[test] +fn private_data_of_a_form_xobject_is_removed() { + let doc = owners_fixture("", "/PieceInfo << /App << /Private (LEAK_PRIVATE) >> >>", vec![]); + removes_from_every_owner(doc, crate::sanitize::Hidden::PrivateData, b"LEAK_"); +} + +#[test] +fn metadata_of_an_image_is_removed() { + let doc = owners_fixture("/Metadata 9 0 R", "", vec![stream("/Type /Metadata /Subtype /XML", b"LEAK_XMP")]); + removes_from_every_owner(doc, crate::sanitize::Hidden::Metadata, b"LEAK_"); +} + +#[test] +fn a_pages_associated_files_are_removed_with_attachments() { + let mut doc = owners_fixture( + "", + "", + vec![b"<< /Type /Filespec /F (a.txt) /AFRelationship /Data /EF << /F 10 0 R >> >>".to_vec(), stream("/Type /EmbeddedFile", b"LEAK_ATTACHED")], + ); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"AF".to_vec(), ref_array(&[9]))).unwrap(); + removes_from_every_owner(doc, crate::sanitize::Hidden::Attachments, b"LEAK_"); +} + +#[test] +fn a_fields_own_actions_are_removed_with_links_actions_and_scripts() { + let mut doc = owners_fixture( + "", + "", + vec![ + b"<< /FT /Tx /T (total) /AA << /C << /S /JavaScript /JS (LEAK_SCRIPT) >> >> /Kids [10 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [10 200 150 220] >>".to_vec(), + ], + ); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[9]))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[10]))).unwrap(); + removes_from_every_owner(doc, crate::sanitize::Hidden::LinksActionsScripts, b"LEAK_"); +} + +/// Resource names are chosen by the producer: a font named like an owned entry stays. +#[test] +fn a_resource_named_like_an_owned_entry_is_kept() { + let mut doc = one_page( + b"BT /AF 10 Tf 10 100 Td (Visible) Tj ET", + "", + vec![b"<< /Type /Filespec /F (a.txt) /EF << /F 8 0 R >> >>".to_vec(), stream("/Type /EmbeddedFile", b"LEAK_ATTACHED")], + ); + doc.update_dict(ObjRef::new(3, 0), |d| { + let mut fonts = Dict::new(); + fonts.set(b"AF".to_vec(), Object::Ref(ObjRef::new(5, 0))); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + d.set(b"AF".to_vec(), ref_array(&[7])); + }) + .unwrap(); + removes_from_every_owner(doc.clone(), crate::sanitize::Hidden::Attachments, b"LEAK_"); + crate::sanitize::remove_hidden(&mut doc, &[crate::sanitize::Hidden::Attachments]).unwrap(); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + assert!(res.get(b"Font").and_then(|f| f.as_dict()).is_some_and(|f| f.contains(b"AF"))); + assert!(!page.dict.contains(b"AF")); +} + +/// Owners written in place deeper than the sweep follows are refused, not skipped. +#[test] +fn owners_nested_too_deeply_are_refused() { + use crate::sanitize::{Hidden, remove_hidden}; + let nested = format!("{}<< /AA << /O << /S /JavaScript /JS (LEAK_DEEP) >> >> >>{}", "[".repeat(100), "]".repeat(100)); + let mut doc = one_page(b"BT ET", "", vec![format!("<< /Deep {nested} >>").into_bytes()]); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"PieceInfo".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap(); + let out = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]); + assert!(matches!(out, Err(RedactError::TooDeep)), "{out:?}"); +} From 94563649e604acbd3fb2a8c3cca0185b63dd2493 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:22:48 +0000 Subject: [PATCH 143/517] fix(redact): name the owner sweep's holder list for clippy --- crates/redact/src/sanitize.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index d9edfbde95..7349c433ae 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -215,10 +215,13 @@ impl Held { } } +/// Indirect objects that hold an owned entry, each with the role it was reached in. +type Holders = Vec<(ObjRef, Held)>; + /// The objects reachable from the catalog that hold one of `keys` outside a name map, and how /// many dictionaries hold each key. Each object is read at most once per [`Held`] role, so a /// shared or cyclic graph costs one step per object. -fn owners(doc: &Document, keys: &[&[u8]]) -> Result<(Vec, Vec<(ObjRef, Held)>), RedactError> { +fn owners(doc: &Document, keys: &[&[u8]]) -> Result<(Vec, Holders), RedactError> { let mut held = vec![0; keys.len()]; let mut holders = Vec::new(); let Some(root) = doc.root() else { return Ok((held, holders)) }; From 4d64ca8e2da819dff1d0e08c63939560d3b62a78 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:23:26 +0000 Subject: [PATCH 144/517] docs(redact): say which objects hidden-information removal reaches --- crates/redact/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index 9c2793e248..2caf067b5b 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -71,7 +71,11 @@ sanitize::sanitize(&mut doc)?; // all of them Categories: metadata (`/Info`, XMP), file attachments, comments, form fields (flattened so the values stay visible), hidden text (render modes 3 and 7, or wholly off the page), hidden layers (content of off optional-content groups, then the groups), bookmarks, links/actions/JavaScript, -and `/PieceInfo` private data. Both make the next save a full rewrite. +and `/PieceInfo` private data. XMP metadata, private data, associated files (`/AF`) and +additional actions (`/AA`) are removed from every object reachable from the catalog that owns +them (images, fonts, Forms, annotations and fields as well as the catalog and pages), never from +name maps such as resource dictionaries. Content is read strictly: a page whose content can't be +read in full is refused, not cleaned in part. Both make the next save a full rewrite. `patterns` finds phone numbers, e-mail addresses, credit card numbers (Luhn-checked), US Social Security numbers and dates for Search & Redact. From 241c5512d646e9be2964ccba96a69be5ab579bb5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:04:40 +0000 Subject: [PATCH 145/517] fix(redact): charge repeated name-tree kids to the node budget The name-tree count cloned each node's /Kids array and skipped repeated references without charging the budget, so a node listing the same kid many times cost work proportional to every entry while the budget saw one step. The walk now borrows the array, charges every entry it does not descend into, and stops once the budget is spent. --- crates/redact/src/sanitize.rs | 30 ++++++++++++++++++++---------- crates/redact/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 7349c433ae..5b2918f01c 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -92,28 +92,38 @@ const NAME_TREE_NODE_BUDGET: usize = 100_000; /// Entries of a name tree (`/Names` leaves, `/Kids` recursion). Each indirect node is read once, /// however many `/Kids` arrays list it, so shared or cyclic kids cost one step per node rather -/// than one per path; depth and a total node budget bound the rest. +/// than one per path. Every `/Kids` entry the walk does not descend into (a repeated reference or +/// a non-dictionary) costs a step as well, so a long `/Kids` array of repeats cannot outrun the +/// budget. Depth and a total step budget bound the rest. fn name_tree_len(doc: &Document, node: &Dict) -> usize { let (mut seen, mut visited) = (std::collections::HashSet::new(), 0); name_tree_len_seen(doc, node, 0, &mut seen, &mut visited) } fn name_tree_len_seen(doc: &Document, node: &Dict, depth: usize, seen: &mut std::collections::HashSet, visited: &mut usize) -> usize { - *visited += 1; + *visited = visited.saturating_add(1); if depth > 32 || *visited > NAME_TREE_NODE_BUDGET { return 0; } let leaves = node.get(b"Names").and_then(|n| doc.resolve(n).as_array().map(|a| a.len() / 2)).unwrap_or(0); - let kids = node.get(b"Kids").and_then(|k| doc.resolve(k).as_array().cloned()).unwrap_or_default(); let mut n = leaves; - for k in &kids { - if let Object::Ref(r) = k - && !seen.insert(*r) - { - continue; + let kids = node.get(b"Kids").map(|k| doc.resolve(k)); + for k in kids.as_deref().and_then(Object::as_array).map_or(&[][..], Vec::as_slice) { + if *visited > NAME_TREE_NODE_BUDGET { + break; } - if let Some(kid) = doc.resolve(k).as_dict() { - n = n.saturating_add(name_tree_len_seen(doc, kid, depth + 1, seen, visited)); + let held; + let kid = match k { + Object::Ref(r) if !seen.insert(*r) => None, + Object::Ref(r) => { + held = doc.get(*r); + held.as_dict() + } + other => other.as_dict(), + }; + match kid { + Some(kid) => n = n.saturating_add(name_tree_len_seen(doc, kid, depth + 1, seen, visited)), + None => *visited = visited.saturating_add(1), } } n diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 603fede2c7..a9902495df 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2671,3 +2671,23 @@ fn owners_nested_too_deeply_are_refused() { let out = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]); assert!(matches!(out, Err(RedactError::TooDeep)), "{out:?}"); } + +/// A name tree node that lists the same kid more times than the node budget: each repeat costs a +/// step, so the walk stops at the budget instead of scanning every repeat, and the distinct kid +/// listed after them is never reached. +#[test] +fn repeated_name_tree_kids_count_towards_the_node_budget() { + use crate::sanitize::{Hidden, scan}; + let repeats = "6 0 R ".repeat(100_010); + let doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 5 0 R >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b"BT ET"), + format!("<< /Kids [{repeats}7 0 R] >>").into_bytes(), + b"<< /Names [(a.txt) << /Type /Filespec /F (a.txt) >>] >>".to_vec(), + b"<< /Names [(b.txt) << /Type /Filespec /F (b.txt) >>] >>".to_vec(), + ]); + let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); + assert_eq!(counts[&Hidden::Attachments], 1, "the walk went past its node budget to the last kid"); +} From a374d16515b9aeec2dc6dbc2ba9dac1a648b02f0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:30:00 +0000 Subject: [PATCH 146/517] fix(cos): index the keys of a large dictionary Dict::set searched every existing entry before inserting a key, so a parsed dictionary of n distinct keys cost n^2/2 key comparisons: a few megabytes of keys stalled parsing for minutes. From 32 entries a dictionary keeps a key index, so each insert and lookup is one hash probe. Equality and debug output still compare and show the entries alone. --- crates/cos/src/object.rs | 87 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 438b8d5f35..444fa37514 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -76,10 +76,35 @@ impl PdfString { /// A name object (without the leading `/`, with `#xx` escapes already decoded). pub type Name = Vec; +#[cfg(test)] +thread_local! { + /// Key comparisons and index lookups made by [`Dict`] on this thread. + static KEY_COMPARES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Entries from which a [`Dict`] keeps a key index. Smaller dictionaries (nearly all of them) +/// search their entries; a parsed file controls the key count, and a linear search per +/// inserted key would make a dictionary of many distinct keys quadratic to build. +const INDEX_FROM: usize = 32; + /// An ordered dictionary. -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Default)] pub struct Dict { entries: Vec<(Name, Object)>, + /// Key to position in `entries`, kept once the dictionary reaches [`INDEX_FROM`] entries. + index: Option>>, +} + +impl PartialEq for Dict { + fn eq(&self, other: &Self) -> bool { + self.entries == other.entries + } +} + +impl std::fmt::Debug for Dict { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Dict").field("entries", &self.entries).finish() + } } impl Dict { @@ -87,26 +112,53 @@ impl Dict { Self::default() } + fn position(&self, key: &[u8]) -> Option { + if let Some(index) = &self.index { + #[cfg(test)] + KEY_COMPARES.with(|c| c.set(c.get() + 1)); + return index.get(key).copied(); + } + self.entries.iter().position(|(k, _)| { + #[cfg(test)] + KEY_COMPARES.with(|c| c.set(c.get() + 1)); + k == key + }) + } + pub fn get(&self, key: &[u8]) -> Option<&Object> { - self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v) + self.position(key).and_then(|i| self.entries.get(i)).map(|(_, v)| v) } pub fn get_mut(&mut self, key: &[u8]) -> Option<&mut Object> { - self.entries.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v) + let i = self.position(key)?; + self.entries.get_mut(i).map(|(_, v)| v) } /// Insert or replace, keeping the position of an existing key. pub fn set(&mut self, key: impl Into, value: impl Into) { let key = key.into(); let value = value.into(); - match self.entries.iter_mut().find(|(k, _)| *k == key) { + match self.position(&key).and_then(|i| self.entries.get_mut(i)) { Some((_, v)) => *v = value, - None => self.entries.push((key, value)), + None => { + let at = self.entries.len(); + if let Some(index) = &mut self.index { + index.insert(key.clone(), at); + } else if at + 1 >= INDEX_FROM { + let index = self.entries.iter().enumerate().map(|(i, (k, _))| (k.clone(), i)).chain([(key.clone(), at)]).collect(); + self.index = Some(Box::new(index)); + } + self.entries.push((key, value)); + } } } pub fn remove(&mut self, key: &[u8]) -> Option { - let i = self.entries.iter().position(|(k, _)| k == key)?; + let i = self.position(key)?; + if let Some(index) = &mut self.index { + index.remove(key); + index.values_mut().filter(|at| **at > i).for_each(|at| *at -= 1); + } Some(self.entries.remove(i).1) } @@ -382,6 +434,29 @@ impl From for Object { mod tests { use super::*; + #[test] + fn parsing_many_distinct_keys_is_not_quadratic() { + let n = 20_000; + let body: String = (0..n).map(|i| format!("/K{i} {i} ")).collect(); + let text = format!("<< {body}/K7 true >>"); + KEY_COMPARES.with(|c| c.set(0)); + let Ok(Object::Dict(d)) = crate::parser::Lexer::new(text.as_bytes(), 0).object() else { panic!("not a dict") }; + let compares = KEY_COMPARES.with(|c| c.get()); + assert!(compares < 8 * n, "{compares} key comparisons for {n} keys"); + // Order and replace-in-place semantics stay those of a plain ordered dictionary. + assert_eq!(d.len(), n); + assert_eq!(d.iter().next().map(|(k, _)| k.as_slice()), Some(&b"K0"[..])); + assert_eq!(d.get(b"K7"), Some(&Object::Bool(true))); + assert_eq!(d.iter().nth(7).map(|(_, v)| v), Some(&Object::Bool(true))); + let mut d = d; + assert_eq!(d.remove(b"K3"), Some(Object::Int(3))); + assert_eq!(d.get(b"K4"), Some(&Object::Int(4))); + assert_eq!(d.iter().nth(3).map(|(k, _)| k.as_slice()), Some(&b"K4"[..])); + d.set(b"K3".to_vec(), Object::Null); + assert_eq!(d.iter().last().map(|(k, _)| k.as_slice()), Some(&b"K3"[..])); + assert!(d.get(b"K20000").is_none()); + } + #[test] fn text_strings_round_trip() { for s in ["Plain ASCII", "Café — naïve", "日本語", "emoji 📄"] { From 58dc10c227e27a5bb5873c12894c8cf8ed61e76f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:33:20 +0000 Subject: [PATCH 147/517] fix(cos): read a damaged real's numeric prefix in one pass To read the longest valid prefix of a token like 1.2.3 the parser retried the conversion on each shorter prefix, so a long digit run followed by long junk was converted once per junk byte: quadratic in the token. The prefix is found in one scan and converted once. A prefix that spells infinity or NaN no longer reads as a number. --- crates/cos/src/parser.rs | 68 +++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 0458055961..a16ff10c37 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -324,18 +324,8 @@ impl<'a> Lexer<'a> { if s.contains('.') { // Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix. let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1); - let mut end = cleaned.len(); - while end > 0 { - // Only cut at character boundaries: a token can hold multi-byte UTF-8 (fuzzing - // found ".—", which panicked here). - if cleaned.is_char_boundary(end) - && let Ok(v) = cleaned[..end].parse::() - { - return Ok(Object::Real(v)); - } - end -= 1; - } - return Ok(Object::Real(0.0)); + let v = cleaned.get(..real_prefix_len(cleaned.as_bytes())).and_then(|p| parse_counted::(p).ok()); + return Ok(Object::Real(v.unwrap_or(0.0))); } // Tolerate a doubled sign ("--280") like the real branch does. let t = s.trim_start_matches('+'); @@ -350,6 +340,44 @@ impl<'a> Lexer<'a> { } } +/// Length of the longest prefix of `s` that reads as a decimal number: a sign, digits with at +/// most one `.`, and an exponent with digits. Zero when that prefix has no digit. Found in one +/// pass so a long damaged token is converted once, not once per shorter prefix. +fn real_prefix_len(s: &[u8]) -> usize { + let digits = |from: usize| s.get(from..).map_or(0, |r| r.iter().take_while(|b| b.is_ascii_digit()).count()); + let mut at = usize::from(matches!(s.first(), Some(b'+' | b'-'))); + let int = digits(at); + at += int; + let mut frac = 0; + if s.get(at) == Some(&b'.') { + frac = digits(at + 1); + at += 1 + frac; + } + if int + frac == 0 { + return 0; + } + if matches!(s.get(at), Some(b'e' | b'E')) { + let sign = usize::from(matches!(s.get(at + 1), Some(b'+' | b'-'))); + let exp = digits(at + 1 + sign); + if exp > 0 { + at += 1 + sign + exp; + } + } + at +} + +#[cfg(test)] +thread_local! { + /// Bytes handed to number conversion on this thread. + static NUMBER_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn parse_counted(s: &str) -> Result { + #[cfg(test)] + NUMBER_BYTES.with(|c| c.set(c.get() + s.len())); + s.parse() +} + fn hexval(b: u8) -> Option { match b { b'0'..=b'9' => Some(b - b'0'), @@ -483,6 +511,22 @@ mod tests { assert_eq!(Lexer::new("1.5\u{2014}".as_bytes(), 0).object().unwrap(), Object::Real(1.5)); } + #[test] + fn a_damaged_real_is_converted_once() { + let token = format!("1.{}{}", "5".repeat(2_000), "x".repeat(20_000)); + NUMBER_BYTES.with(|c| c.set(0)); + let Ok(Object::Real(v)) = Lexer::new(token.as_bytes(), 0).object() else { panic!("not a real") }; + let converted = NUMBER_BYTES.with(|c| c.get()); + assert!(converted <= token.len(), "{converted} bytes converted for a {}-byte token", token.len()); + assert!((v - 1.5555).abs() < 1e-3); + // The longest numeric prefix wins, as before. + for (t, want) in + [("1.2.3", 1.2), ("--5.", -5.0), ("+.5x", 0.5), ("5.-", 5.0), ("1.5e3", 1500.0), ("1.5e+", 1.5), ("-.x", 0.0), (".", 0.0), ("-inf.", 0.0)] + { + assert_eq!(Lexer::new(t.as_bytes(), 0).object().ok(), Some(Object::Real(want)), "{t}"); + } + } + fn obj(s: &str) -> Object { Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}")) } From 3c64f9314ef2b6d749dfcfb4abe9652e226208b3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:34:53 +0000 Subject: [PATCH 148/517] fix(content): skip a long unreadable number whole When an operand failed to parse, the content parser went back to the byte after its start and tried again, so a run of n bytes that never reads as a number (a long run of '+', or digits ending in a letter) was read again from every one of its bytes: n^2/2 bytes from a tiny compressed stream. A failed number token longer than 64 bytes is now skipped whole; shorter ones still recover byte by byte. --- crates/content/src/lib.rs | 19 ++++++++++++++++--- crates/content/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index eb126ffa21..04bc1cfa95 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -74,6 +74,9 @@ fn is_delim(b: u8) -> bool { matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') } +/// Longest unreadable number token retried from its next byte. Real numbers are far shorter. +const MAX_RETRIED_TOKEN: usize = 64; + /// Parse a (decoded) content stream. pub fn parse(data: &[u8]) -> Parsed { parse_pieces(data, &[]) @@ -110,9 +113,19 @@ fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed { lx.pos = data.len(); } Err(_) => { - // Skip the offending byte and carry on. - lx.pos = at + 1; - out.skipped += 1; + let read = lx.pos.saturating_sub(at); + #[cfg(test)] + tests::RESCANNED.with(|c| c.set(c.get() + read)); + let number = matches!(b, b'+' | b'-' | b'.') || b.is_ascii_digit(); + if number && read > MAX_RETRIED_TOKEN { + // A long unreadable number is skipped whole: retrying from each of its + // bytes would read the rest of it again every time. + out.skipped += read; + } else { + // Skip the offending byte and carry on. + lx.pos = at + 1; + out.skipped += 1; + } } } continue; diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 094a0665df..f32ab3da50 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -1,5 +1,27 @@ use super::*; +thread_local! { + /// Bytes the lexer read before an operand failed, on this thread. + pub(crate) static RESCANNED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[test] +fn a_long_unreadable_number_is_read_once() { + for junk in ["+", "5"] { + let n = 20_000; + let src = format!("{}{} 1 0 0 1 0 0 cm", junk.repeat(n), if junk == "5" { "x" } else { "" }); + RESCANNED.with(|c| c.set(0)); + let p = parse(src.as_bytes()); + let rescanned = RESCANNED.with(|c| c.get()); + assert!(rescanned <= 2 * n, "{rescanned} bytes read for a {n}-byte token"); + assert_eq!(p.ops.last().and_then(|o| o.nums::<6>()), Some([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + } + // A short damaged number still recovers byte by byte. + let p = parse(b"-+5 0 Td"); + assert_eq!((p.ops.len(), p.skipped), (1, 1)); + assert_eq!(p.ops[0].nums::<2>(), Some([5.0, 0.0])); +} + #[test] fn operators_keep_operands_and_spans() { let src = b"q 1 0 0 1 72 720 cm BT /F1 12 Tf (Hello \\(world\\)) Tj [(A) -120 (B)] TJ ET Q"; From 07aea983d0b5df84b474f28d374b143254e5c41f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:35:25 +0000 Subject: [PATCH 149/517] fix(content): splice copies only the pieces a range touches Pieces::splice copied the unedited bytes between edits by visiting every piece for every edit, so a page of many content pieces and many replaced operators cost pieces x edits steps. The copy starts at the piece holding its first byte and stops past its last one. --- crates/content/src/lib.rs | 13 ++++++++++++- crates/content/src/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index 04bc1cfa95..986bab495e 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -504,9 +504,20 @@ impl Pieces { /// for byte. Returns every piece's new bytes (unchanged pieces come back equal). pub fn splice(&self, edits: impl IntoIterator, Vec)>) -> Vec> { let mut out: Vec> = (0..self.len()).map(|i| Vec::with_capacity(self.range(i).len())).collect(); + // Only the pieces from the one holding `from` up to `to`: one call per edit must not + // visit every piece. let copy = |out: &mut Vec>, from: usize, to: usize| { - for (i, piece) in out.iter_mut().enumerate() { + if from >= to { + return; + } + let first = self.piece_of(from); + for (i, piece) in out.iter_mut().enumerate().skip(first) { + #[cfg(test)] + tests::PIECE_VISITS.with(|c| c.set(c.get() + 1)); let r = self.range(i); + if r.start >= to { + break; + } let (a, b) = (from.max(r.start), to.min(r.end)); if a < b { piece.extend_from_slice(self.data.get(a..b).unwrap_or_default()); diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index f32ab3da50..acf494320b 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -5,6 +5,30 @@ thread_local! { pub(crate) static RESCANNED: std::cell::Cell = const { std::cell::Cell::new(0) }; } +thread_local! { + /// Pieces [`Pieces::splice`] looked at while copying, on this thread. + pub(crate) static PIECE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[test] +fn splicing_visits_each_piece_a_few_times() { + let n = 5_000; + let pieces: Vec = (0..n).map(|i| format!("({i}) Tj")).collect(); + let joined = Pieces::join(&pieces); + let parsed = joined.parse(); + let edits: Vec<_> = parsed.ops.iter().map(|op| (op.span.clone(), b"(x) Tj".to_vec())).collect(); + assert_eq!(edits.len(), n); + PIECE_VISITS.with(|c| c.set(0)); + let out = joined.splice(edits); + let visits = PIECE_VISITS.with(|c| c.get()); + assert!(visits <= 4 * n, "{visits} piece visits for {n} pieces"); + assert!(out.iter().all(|p| p == b"(x) Tj")); + // A replacement spanning two pieces stays whole in the first and leaves the rest of the second. + let two = Pieces::join(&["q (a)", "Tj Q"]); + let tj = two.parse().ops.into_iter().find(|o| o.is("Tj")).map(|o| o.span).unwrap_or_default(); + assert_eq!(two.splice([(tj, b"(b) Tj".to_vec())]), [b"q (b) Tj".to_vec(), b" Q".to_vec()]); +} + #[test] fn a_long_unreadable_number_is_read_once() { for junk in ["+", "5"] { From e2c71b60e5e1f5f8aad7da8cbabcdf1626dbe89c Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:36:19 +0000 Subject: [PATCH 150/517] fix(filters): refuse a filter chain longer than eight Every stage of a stream's filter chain could produce up to the output limit and nothing bounded the number of stages, so a long /Filter array over data that decodes to itself (FF FF under RunLengthDecode) repeated the full work once per array entry. Chains of more than eight filters are unsupported, like an image codec, and stay encoded. --- crates/filters/src/lib.rs | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 9a0124d859..87306166c4 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -137,8 +137,8 @@ pub(crate) type Step = Result, Failure>; /// Decodes `data` through `chain` (first element applied first). /// /// Image codecs, `Crypt` and unknown filters anywhere in the chain give -/// [`FilterError::Unsupported`] (callers keep such data encoded). `max_output` bounds -/// every intermediate buffer. +/// [`FilterError::Unsupported`] (callers keep such data encoded), as does a chain longer than +/// [`MAX_CHAIN`]. `max_output` bounds every intermediate buffer. pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result, FilterError> { run(chain, data, max_output, true).map(|(v, _)| v) } @@ -150,13 +150,22 @@ pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usiz run(chain, data, max_output, false) } +/// Longest filter chain decoded. Real streams use one to three filters; every stage may produce +/// up to the output limit, so the chain length multiplies the work. +pub const MAX_CHAIN: usize = 8; + fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool) -> Result<(Vec, bool), FilterError> { + if chain.len() > MAX_CHAIN { + return Err(FilterError::Unsupported(format!("a chain of {} filters", chain.len()))); + } if let Some((f, _)) = chain.iter().find(|(f, _)| !f.is_decodable()) { return Err(FilterError::Unsupported(f.name().to_owned())); } let mut cur: Cow<[u8]> = Cow::Borrowed(data); let mut partial = false; for (filter, params) in chain { + #[cfg(test)] + STAGE_BYTES.with(|c| c.set(c.get() + cur.len())); cur = Cow::Owned(match decode_one(filter, params, &cur, max) { Ok(v) => v, Err(Failure { error: FilterError::Corrupt { .. }, partial: out }) if !strict => { @@ -218,8 +227,27 @@ pub(crate) fn is_pdf_whitespace(b: u8) -> bool { matches!(b, 0 | 9 | 10 | 12 | 13 | 32) } +#[cfg(test)] +thread_local! { + /// Bytes handed to decoding stages on this thread. + static STAGE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + #[cfg(test)] mod tests { + #[test] + fn a_long_filter_chain_is_refused_before_decoding() { + // `FF FF` run-length decodes to itself, so every stage of the chain repeats the work. + let data = vec![0xFF; 4096]; + let chain = |n: usize| vec![(super::Filter::RunLength, super::Params::default()); n]; + super::STAGE_BYTES.with(|c| c.set(0)); + let long = super::decode(&chain(1000), &data, 1 << 20); + let read = super::STAGE_BYTES.with(|c| c.get()); + assert!(read <= 16 * data.len(), "{read} bytes decoded through the chain"); + assert!(matches!(long, Err(super::FilterError::Unsupported(_))), "{long:?}"); + assert_eq!(super::decode(&chain(super::MAX_CHAIN), &data, 1 << 20).ok(), Some(data)); + } + use super::*; #[test] From 92e713feb1ac2dac75b26bdc2d2952ed44d294ee Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:37:09 +0000 Subject: [PATCH 151/517] fix(cos): a /Filter that is not a name does not decode as unfiltered Stream::filters reads only a direct name or array of names, so an indirect /Filter (or an array holding a reference) gave an empty chain and every decode returned the encoded bytes as if they were the data: a strict decode reported success on content it never decoded, while a viewer resolving the reference shows something else. Decoding such a stream is an error, so callers keep it encoded or refuse it. --- crates/cos/src/object.rs | 45 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 444fa37514..6b6a4befe2 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -256,6 +256,21 @@ impl Stream { .collect() } + /// [`Stream::filters`], or an error when `/Filter` is present but is not a name or an + /// array of names (an indirect reference, say): the data is not unfiltered, so it must not + /// read as decoded. + fn readable_filters(&self) -> Result, CosError> { + let readable = match self.dict.get(b"Filter") { + None | Some(Object::Null | Object::Name(_)) => true, + Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some()), + Some(_) => false, + }; + if !readable { + return Err(CosError::Filter("/Filter is not a name or an array of names".into())); + } + Ok(self.filters()) + } + /// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data /// decoded before the damage is returned). Streams with image codecs fail with /// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage. @@ -266,7 +281,7 @@ impl Stream { /// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than /// [`MAX_DECODED`]: decoding past `max` bytes is an error. pub fn decoded_within(&self, max: usize) -> Result, CosError> { - let chain = self.filters(); + let chain = self.readable_filters()?; if chain.is_empty() { return self.raw_within(max); } @@ -275,7 +290,7 @@ impl Stream { /// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error. pub fn decoded_strict(&self) -> Result, CosError> { - let chain = self.filters(); + let chain = self.readable_filters()?; if chain.is_empty() { return self.raw_within(MAX_DECODED); } @@ -285,7 +300,7 @@ impl Stream { /// Data after the first filter alone, bounded by `max`: the rest of the chain stays encoded. /// Any corruption in that first filter is an error. pub fn decoded_first_filter_strict(&self, max: usize) -> Result, CosError> { - let mut chain = self.filters(); + let mut chain = self.readable_filters()?; chain.truncate(1); if chain.is_empty() { return self.raw_within(max); @@ -434,6 +449,30 @@ impl From for Object { mod tests { use super::*; + #[test] + fn a_filter_that_is_not_a_direct_name_is_not_decoded_as_absent() { + let stream = |filter: Object| { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), filter); + Stream::from_raw(d, b"48 69>".to_vec()) + }; + let hex = Object::Name(b"ASCIIHexDecode".to_vec()); + assert_eq!(stream(hex.clone()).decoded_strict().ok(), Some(b"Hi".to_vec())); + assert_eq!(stream(Object::Array(vec![hex.clone()])).decoded().ok(), Some(b"Hi".to_vec())); + assert_eq!(stream(Object::Null).decoded_strict().ok(), Some(b"48 69>".to_vec())); + for filter in [ + Object::Ref(ObjRef::new(5, 0)), + Object::Array(vec![Object::Ref(ObjRef::new(5, 0))]), + Object::Int(3), + Object::Array(vec![hex, Object::Int(1)]), + ] { + let s = stream(filter.clone()); + assert!(s.decoded_strict().is_err(), "{filter:?}"); + assert!(s.decoded().is_err(), "{filter:?}"); + assert!(s.decoded_first_filter_strict(1 << 20).is_err(), "{filter:?}"); + } + } + #[test] fn parsing_many_distinct_keys_is_not_quadratic() { let n = 20_000; From 73d15cd8b25c38483bd6be7e9d6c0aa59bb92bf7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:38:04 +0000 Subject: [PATCH 152/517] fix(cos): count strings at every depth against the object caches The object caches weigh what each cached object owns, but the walk stopped four levels down and counted anything deeper as nothing. Many objects holding a large string inside a few nested arrays stayed cached far past the byte budget (34 MiB against 32 MiB in the test, and without limit as objects grow). The walk now goes past the parser's nesting limit; anything deeper counts as too large to keep. --- crates/cos/src/document.rs | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index c3f6c6b748..96975eb36e 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -494,12 +494,16 @@ impl ObjectReader { } } +/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object +/// is measured in full. +const OWNED_BYTES_DEPTH: usize = 256; + /// Bytes `object` holds of its own: string and stream data that is not a view into `data` -/// (the input file), nested a few levels deep (deeper data is rare and still bounded by the -/// reader's object count). +/// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured +/// and counts as too large to keep. fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { - if depth > 4 { - return 0; + if depth > OWNED_BYTES_DEPTH { + return usize::MAX; } match object { Object::String(s) => s.bytes.len(), @@ -2993,6 +2997,29 @@ endobj } } + #[test] + fn editor_cache_counts_deeply_nested_strings() { + let mut nested = Object::String(crate::PdfString::literal(vec![b'x'; 1 << 20])); + for _ in 0..8 { + nested = Object::Array(vec![nested]); + } + let mut source = Document::new_empty(); + let objects: Vec<_> = (0..48).map(|_| Object::Ref(source.add(nested.clone()))).collect(); + source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); + let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); + let doc = Document::open(bytes).unwrap(); + assert_eq!(owned_bytes(&nested, &doc.data, 0), 1 << 20); + let root = doc.get(doc.root().unwrap()); + for reference in root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap() { + let Object::Ref(reference) = reference else { panic!("indirect object") }; + drop(doc.get(*reference)); + let cache = doc.cache.lock().unwrap(); + let owned = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0))); + let strings = cache.values().filter(|o| o.as_array().is_some()).count(); + assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted"); + } + } + #[test] fn editor_cache_stays_bounded() { let mut source = Document::new_empty(); From b1224557058aaf568a17f4c5063878d6131c89a2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:39:54 +0000 Subject: [PATCH 153/517] fix(cos): find endstream once per document, not once per stream A stream whose /Length is wrong or missing is ended at the next endstream, which the parser found by searching the rest of the file. Many such streams before one late endstream (a damaged file being reconstructed reads every one) cost the file size per stream. The document lists where endstream occurs the first time it needs it, and each stream looks its end up there. --- crates/cos/src/document.rs | 41 +++++++++++++++++++++++++++++++----- crates/cos/src/parser.rs | 43 ++++++++++++++++++++++++++++++++------ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 96975eb36e..b783a8245d 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex, Weak}; use crate::CosError; use crate::object::{Dict, ObjRef, Object}; -use crate::parser::{Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; +use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; thread_local! { /// The objects being loaded on this thread, innermost last (see `Document::try_get`). @@ -405,6 +405,8 @@ impl std::fmt::Debug for ReadState { #[derive(Clone)] pub struct Document { data: Arc>, + /// Where `endstream` occurs in `data`; see [`EndstreamIndex`]. + endstreams: Arc, source_identity: Arc<()>, entries: Arc, trailer: Dict, @@ -536,6 +538,7 @@ impl Document { pub fn new_empty() -> Self { let mut doc = Document { data: Arc::new(Vec::new()), + endstreams: Arc::default(), source_identity: Arc::new(()), entries: Arc::new(XrefIndex::empty()), trailer: Dict::new(), @@ -618,6 +621,7 @@ impl Document { }; let mut doc = Document { data, + endstreams: Arc::default(), source_identity: Arc::new(()), entries: Arc::new(XrefIndex::empty()), trailer: Dict::new(), @@ -982,7 +986,7 @@ impl Document { match self.xref_entry(num) { None | Some(XrefEntry::Free { .. }) => true, Some(XrefEntry::InFile { offset, .. }) => { - let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num); + let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None, &self.endstreams), Ok((id, _)) if id.num == num); // Offsets may count from a header that does not start the file. usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset)))) } @@ -1180,7 +1184,7 @@ impl Document { length }; let off = offset as usize; - let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve) { + let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve, &self.endstreams) { Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)), _ => None, }; @@ -1246,7 +1250,7 @@ impl Document { if p > 0 && !is_whitespace(data[p - 1]) { continue; } - if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None) + if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None, &self.endstreams) && id.num == num { found = Some((id, o)); // keep the last (newest) definition @@ -2055,7 +2059,7 @@ impl Document { if trailer.as_ref().is_some_and(|((catalog, p), _)| *catalog && *p > at) { continue; } - if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None) + if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None, &self.endstreams) && s.dict.name(b"Type") == Some(b"XRef") && let Some(r) = rank(at, &s.dict) && trailer.as_ref().is_none_or(|(best, _)| r > *best) @@ -2997,6 +3001,33 @@ endobj } } + #[test] + fn streams_without_length_share_one_endstream_search() { + let n = 2_000; + let mut file = b"%PDF-1.7\n".to_vec(); + for k in 1..=n { + file.extend_from_slice(format!("{k} 0 obj\n<< >>\nstream\nxx\n").as_bytes()); + } + file.extend_from_slice(b"endstream\nendobj\n"); + file.extend_from_slice( + format!( + "{0} 0 obj\n<< /Type /Catalog /Pages {1} 0 R >>\nendobj\n{1} 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n", + n + 1, + n + 2 + ) + .as_bytes(), + ); + file.extend_from_slice(format!("trailer\n<< /Root {} 0 R /Size {} >>\n%%EOF\n", n + 1, n + 3).as_bytes()); + let len = file.len(); + crate::parser::ENDSTREAM_SCAN.with(|c| c.set(0)); + let doc = Document::open(Arc::new(file)).unwrap(); + for k in [1, n / 2, n] { + assert!(matches!(doc.try_get(k).as_deref(), Ok(Object::Stream(_))), "object {k}"); + } + let scanned = crate::parser::ENDSTREAM_SCAN.with(|c| c.get()); + assert!(scanned <= 4 * len, "{scanned} bytes searched in a {len}-byte file"); + } + #[test] fn editor_cache_counts_deeply_nested_strings() { let mut nested = Object::String(crate::PdfString::literal(vec![b'x'; 1 << 20])); diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index a16ff10c37..b470cffa48 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -368,6 +368,8 @@ fn real_prefix_len(s: &[u8]) -> usize { #[cfg(test)] thread_local! { + /// Bytes searched for `endstream` on this thread. + pub(crate) static ENDSTREAM_SCAN: std::cell::Cell = const { std::cell::Cell::new(0) }; /// Bytes handed to number conversion on this thread. static NUMBER_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -390,17 +392,30 @@ fn hexval(b: u8) -> Option { /// Parse an indirect object at `offset`: `N G obj [stream…endstream] endobj`. /// `resolve_length` resolves an indirect `/Length`. Returns the object and its (num, gen). pub fn parse_indirect(data: &[u8], offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option) -> Result<(ObjRef, Object), CosError> { - parse_indirect_in(data, None, offset, resolve_length) + parse_indirect_in(data, None, offset, resolve_length, None) } /// [`parse_indirect`] for a document's own bytes: stream data points into `data` instead of /// being copied out of it. +/// `endstreams` is the document's [`EndstreamIndex`] for `data`. pub(crate) fn parse_indirect_shared( data: &Arc>, offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option, + endstreams: &EndstreamIndex, ) -> Result<(ObjRef, Object), CosError> { - parse_indirect_in(data, Some(data), offset, resolve_length) + parse_indirect_in(data, Some(data), offset, resolve_length, Some(endstreams)) +} + +/// Where `endstream` occurs in a document's bytes, found once, the first time a stream's +/// /Length is wrong or missing. Searching the rest of the file for each such stream instead +/// makes many of them ending at one late `endstream` quadratic to read. +pub(crate) type EndstreamIndex = std::sync::OnceLock>; + +fn endstream_offsets(data: &[u8]) -> Vec { + #[cfg(test)] + ENDSTREAM_SCAN.with(|c| c.set(c.get() + data.len())); + data.windows(9).enumerate().filter(|(_, w)| *w == b"endstream").map(|(i, _)| i).collect() } fn parse_indirect_in( @@ -408,6 +423,7 @@ fn parse_indirect_in( shared: Option<&Arc>>, offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option, + endstreams: Option<&EndstreamIndex>, ) -> Result<(ObjRef, Object), CosError> { let mut lx = Lexer::new(data, offset); lx.skip_ws(); @@ -467,7 +483,11 @@ fn parse_indirect_in( let len = match declared { Some(l) if valid(l) => l as usize, // Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). - _ => find_endstream(data, start).ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? - start, + _ => { + find_endstream(data, start, endstreams) + .ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? + - start + } }; let range = start..start + len; let raw = match shared { @@ -483,9 +503,20 @@ fn parse_indirect_in( } /// Offset where stream data ends (before the EOL preceding "endstream"). -fn find_endstream(data: &[u8], from: usize) -> Option { - let hay = data.get(from..)?; - let at = hay.windows(9).position(|w| w == b"endstream")? + from; +fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) -> Option { + let at = match endstreams { + Some(index) => { + let index = index.get_or_init(|| endstream_offsets(data)); + index.get(index.partition_point(|&p| p < from)).copied()? + } + None => { + let hay = data.get(from..)?; + let found = hay.windows(9).position(|w| w == b"endstream"); + #[cfg(test)] + ENDSTREAM_SCAN.with(|c| c.set(c.get() + found.map_or(hay.len(), |p| p + 9))); + found? + from + } + }; let mut end = at; if end > from && data[end - 1] == b'\n' { end -= 1; From 196be22b64c39bb6315a0399db4392fa404e211e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:41:37 +0000 Subject: [PATCH 154/517] fix(cos): read a hybrid cross-reference stream once per chain The materialized cross-reference reader reread a section's /XRefStm for every revision that named it, decoding the stream and inserting its rows each time, although the first read had already merged every entry. Up to 10,000 revisions naming one large stream repeated that work 10,000 times. A stream already read is skipped, with one repair log line. --- crates/cos/src/document.rs | 42 +++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index b783a8245d..470f395cdf 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -496,6 +496,12 @@ impl ObjectReader { } } +#[cfg(test)] +thread_local! { + /// Cross-reference sections read on this thread. + static SECTION_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object /// is measured in full. const OWNED_BYTES_DEPTH: usize = 256; @@ -1748,6 +1754,9 @@ impl Document { let mut revisions = Vec::new(); let mut oddities = Vec::new(); let mut visited = HashSet::new(); + // Hybrid streams already read: a newer section has merged every entry of one, so reading + // it again for each older section that names it adds nothing and repeats the decode. + let (mut hybrids, mut noted) = (HashSet::new(), HashSet::new()); let mut next = Some(first); while let Some(off) = next.take() { if !visited.insert(off) || visited.len() > 10_000 { @@ -1778,7 +1787,12 @@ impl Document { revisions.push(Revision { xref_offset: off, is_stream, header_relative }); // Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the // free entries it gives objects only a cross-reference stream can locate. - if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { + let hybrid = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0); + let repeated = hybrid.is_some_and(|x| !hybrids.insert(x)); + if let Some(x) = hybrid.filter(|x| repeated && noted.insert(*x)) { + log.push(format!("hybrid /XRefStm at {x} named again by the section at {off}; read once")); + } + if let Some(x) = hybrid.filter(|_| !repeated) { let mut stm = BTreeMap::new(); match self.read_section(x as usize, &mut stm, &mut odd, log) { Ok(_) => { @@ -1822,6 +1836,8 @@ impl Document { odd: &mut Vec, log: &mut Vec, ) -> Result<(Dict, bool), CosError> { + #[cfg(test)] + SECTION_READS.with(|c| c.set(c.get() + 1)); let data = &self.data; let mut lx = Lexer::new(data, off); if lx.eat_keyword(b"xref") { @@ -3199,6 +3215,30 @@ endobj out } + /// Many revisions naming one hybrid /XRefStm read it once. + #[test] + fn a_hybrid_stream_named_by_many_revisions_is_read_once() { + let mut out = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0); + let stream = find(&out, b"5 0 obj", 0, out.len()).unwrap(); + let mut prev = rfind(&out, b"\nxref\n", 0).unwrap() + 1; + let revisions = 200; + for _ in 0..revisions { + let at = out.len(); + out.extend_from_slice( + format!("xref\ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(), + ); + prev = at; + } + let doc = Document::open(Arc::new(out)).unwrap(); + SECTION_READS.with(|c| c.set(0)); + let (entries, ..) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap(); + let reads = SECTION_READS.with(|c| c.get()); + assert!(reads <= revisions + 2, "{reads} sections read for {} revisions", revisions + 1); + assert!(matches!(entries.get(&3), Some(XrefEntry::InStream { stream: 4, .. })), "{:?}", entries.get(&3)); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes")); + assert_compact_matches_materialized(&doc); + } + fn text_of(doc: &Document, num: u32, key: &[u8]) -> Option { doc.get(ObjRef::new(num, 0)).as_dict().and_then(|d| d.get(key).and_then(|v| v.as_string().map(|s| s.to_text()))) } From 1127a660310853520b21a2c0f98688aaf2afb4d0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:42:43 +0000 Subject: [PATCH 155/517] fix(crypt): resolve crypt filter methods once per handler Every string and stream decrypted searched the document's /CF filters for the method by name. A file controls both the number of filters and the number of strings, so the search multiplied the two when the filter in use was listed last. The handler resolves the string, stream and embedded-file methods when it is built, and looks named filters up in a map. --- crates/crypt/src/lib.rs | 66 +++++++++++++++++++++++++++++---------- crates/crypt/src/tests.rs | 26 +++++++++++++++ 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index 82b598688a..58e96e7d4f 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -138,9 +138,51 @@ pub struct SecurityHandler { key: Vec, auth: Auth, recovered_user: Option>, + methods: Methods, +} + +/// The crypt filter methods of a handler, resolved once: a document controls both the number of +/// filters and the number of strings, so a search of `/CF` per string would multiply them. +#[derive(Clone, Debug)] +struct Methods { + string: Method, + stream: Method, + embedded_file: Method, + named: std::collections::HashMap, Method>, +} + +impl Methods { + fn new(dict: &EncryptDict) -> Self { + let mut named = std::collections::HashMap::with_capacity(dict.crypt_filters.len()); + for (name, method) in &dict.crypt_filters { + #[cfg(test)] + tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1)); + // The first of two filters with one name wins, as a search would find it. + named.entry(name.clone()).or_insert(*method); + } + let mut methods = Methods { string: Method::Identity, stream: Method::Identity, embedded_file: Method::Identity, named }; + methods.string = methods.get(&dict.str_f); + methods.stream = methods.get(&dict.stm_f); + methods.embedded_file = methods.get(if dict.ef_f.is_empty() { &dict.stm_f } else { &dict.ef_f }); + methods + } + + fn get(&self, name: &[u8]) -> Method { + if name == b"Identity" || name.is_empty() { + return Method::Identity; + } + #[cfg(test)] + tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1)); + self.named.get(name).copied().unwrap_or(Method::Identity) + } } impl SecurityHandler { + fn new(dict: EncryptDict, key: Vec, auth: Auth, recovered_user: Option>) -> Self { + let methods = Methods::new(&dict); + Self { dict, key, auth, recovered_user, methods } + } + /// Authenticate with `password` (tried as owner password, then user password). `None` /// tries the empty password, which opens documents that only have an owner password. pub fn open(dict: EncryptDict, id0: &[u8], password: Option<&str>) -> Result { @@ -177,7 +219,7 @@ impl SecurityHandler { } r => return Err(CryptError::Unsupported(format!("revision {r}"))), }; - Ok(Self { dict, key, auth, recovered_user }) + Ok(Self::new(dict, key, auth, recovered_user)) } /// Upgrade to owner access with the owner (permissions) password, as when a restricted @@ -235,26 +277,16 @@ impl SecurityHandler { self.dict.encrypt_metadata } - fn method_named(&self, name: &[u8]) -> Method { - if name == b"Identity" || name.is_empty() { - return Method::Identity; - } - self.dict.crypt_filters.iter().find(|(n, _)| n == name).map(|(_, m)| *m).unwrap_or(Method::Identity) - } - /// The method for strings or a kind of stream. fn method(&self, stream: Option) -> Method { if self.dict.v < 4 { return Method::Rc4; } match stream { - None => self.method_named(&self.dict.str_f), - Some(StreamKind::Normal) => self.method_named(&self.dict.stm_f), - Some(StreamKind::EmbeddedFile) => { - let eff = if self.dict.ef_f.is_empty() { &self.dict.stm_f } else { &self.dict.ef_f }; - self.method_named(eff) - } - Some(StreamKind::Named(n)) => self.method_named(n), + None => self.methods.string, + Some(StreamKind::Normal) => self.methods.stream, + Some(StreamKind::EmbeddedFile) => self.methods.embedded_file, + Some(StreamKind::Named(n)) => self.methods.get(n), } } @@ -642,7 +674,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result { (d.v, d.r, d.length_bits) = (5, 6, 256); @@ -671,7 +703,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result = const { std::cell::Cell::new(0) }; +} + +#[test] +fn crypt_filters_are_looked_up_once_per_handler() { + let plain = new(Algorithm::Aes128, "", "o", -1); + let mut d = plain.dict().clone(); + let n = 5_000; + let mut filters: Vec<_> = (0..n).map(|i| (format!("F{i}").into_bytes(), Method::Identity)).collect(); + filters.append(&mut d.crypt_filters); + d.crypt_filters = filters; + FILTER_COMPARES.with(|c| c.set(0)); + let h = SecurityHandler::open(d, ID, None).unwrap(); + let strings = 1_000; + let enc = plain.encrypt_string(7, 0, b"secret"); + for _ in 0..strings { + assert_eq!(h.decrypt_string(7, 0, &enc), b"secret"); + assert_eq!(h.decrypt_stream(7, 0, &plain.encrypt_stream(7, 0, b"s", StreamKind::Normal), StreamKind::Normal), b"s"); + } + assert_eq!(h.decrypt_stream(7, 0, b"x", StreamKind::Named(b"F3")), b"x"); + let compares = FILTER_COMPARES.with(|c| c.get()); + assert!(compares <= 2 * n + 3 * strings, "{compares} filter name comparisons"); +} + #[test] fn identity_crypt_filters_leave_data_alone() { let mut d = new(Algorithm::Aes256, "", "o", -1).dict().clone(); From 6905acad2c12e3d7051a2835657c46d5da2b3001 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:44:26 +0000 Subject: [PATCH 156/517] fix(model): bound the inherited values a page walk copies Each leaf page gets its own copy of every attribute it inherits, so a large direct /Resources on the root of a page tree with thousands of leaves was copied thousands of times: a compressed file of a few kilobytes could need tens of gigabytes. The walk weighs each inherited value once where it is declared and charges every copy to a 1 GiB budget; past it the walk is cut off, as an over-large tree already is. --- crates/model/src/lib.rs | 73 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 38bf36f222..886684c6d7 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -82,14 +82,36 @@ pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] { } /// Leaf pages in document order, with inherited attributes resolved. Cycles and absurdly deep -/// trees are cut off rather than followed. +/// trees are cut off rather than followed, as is a tree whose inherited values would take more +/// than [`MAX_INHERITED_COPIES`] bytes to copy into its leaves. pub fn pages(doc: &Document) -> Vec { PageTree::new(doc).collect() } +/// Approximate bytes of inherited values copied into leaf dictionaries by one page walk. +pub const MAX_INHERITED_COPIES: usize = 1 << 30; + +/// Approximate heap bytes of `o`: one [`Object`] per node plus string, name and key bytes. +/// Values nested deeper than the parser allows weigh as too large. +fn weight(o: &Object, depth: usize) -> usize { + if depth > 256 { + return usize::MAX; + } + let own = std::mem::size_of::(); + match o { + Object::String(s) => own.saturating_add(s.bytes.len()), + Object::Name(n) => own.saturating_add(n.len()), + Object::Array(a) => a.iter().fold(own, |n, v| n.saturating_add(weight(v, depth + 1))), + Object::Dict(d) => d.iter().fold(own, |n, (k, v)| n.saturating_add(k.len()).saturating_add(weight(v, depth + 1))), + Object::Stream(s) => s.dict.iter().fold(own, |n, (k, v)| n.saturating_add(k.len()).saturating_add(weight(v, depth + 1))), + _ => own, + } +} + // Retain the owner of each inherited value rather than deep-cloning direct Resources on -// every edge. Only the final Page dictionaries need owned copies of those values. -type Inherited = [Option>; 4]; +// every edge. Only the final Page dictionaries need owned copies of those values. Each owner +// carries the value's weight, measured once where the value is declared. +type Inherited = [Option<(std::sync::Arc, usize)>; 4]; struct Children { owner: std::sync::Arc, @@ -112,16 +134,23 @@ struct PageTree<'a> { // One cursor per ancestor, never one entry per child. This also preserves the first // depth-first occurrence's attributes when malformed trees share a child. pending: Vec, + // Weight left for copying inherited values into leaves. A large direct value on an + // ancestor of many leaves would otherwise be copied once per leaf. + budget: usize, } impl<'a> PageTree<'a> { fn new(doc: &'a Document) -> Self { + Self::with_budget(doc, MAX_INHERITED_COPIES) + } + + fn with_budget(doc: &'a Document, budget: usize) -> Self { let top = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))); let pending = top .into_iter() .map(|r| Children { owner: std::sync::Arc::new(Object::Array(vec![Object::Ref(r)])), next: 0, inherited: Default::default() }) .collect(); - Self { doc, seen: Default::default(), pending } + Self { doc, seen: Default::default(), pending, budget } } } @@ -149,8 +178,8 @@ impl Iterator for PageTree<'_> { let Some(d) = obj.as_dict() else { continue }; let mut inherited = parent.inherited.clone(); for (key, owner) in INHERITABLE.iter().zip(&mut inherited) { - if d.contains(key) { - *owner = Some(obj.clone()); + if let Some(value) = d.get(key) { + *owner = Some((obj.clone(), weight(value, 0))); } } let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); @@ -165,8 +194,15 @@ impl Iterator for PageTree<'_> { let mut dict = d.clone(); for (key, owner) in INHERITABLE.iter().zip(&inherited) { if !dict.contains(key) - && let Some(value) = owner.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(key)) + && let Some((owner, weight)) = owner + && let Some(value) = owner.as_dict().and_then(|d| d.get(key)) { + // Past the budget the walk is cut off, like a tree too large to follow. + let Some(left) = self.budget.checked_sub(*weight) else { + self.pending.clear(); + return None; + }; + self.budget = left; dict.set(key.to_vec(), value.clone()); } } @@ -209,6 +245,29 @@ mod tests { Document::open(Arc::new(out)).unwrap() } + #[test] + fn inherited_copies_stop_at_a_budget() { + let mut d = doc(0); + let root = ObjRef { num: 2, generation: 0 }; + let leaf = d.get(ObjRef { num: 3, generation: 0 }).as_dict().unwrap().clone(); + let kids: Vec<_> = (0..200).map(|_| Object::Ref(d.add(leaf.clone()))).collect(); + let mut resources = Dict::new(); + resources.set(b"Payload".to_vec(), Object::Array(vec![Object::Int(42); 8192])); + let resources = Object::Dict(resources); + let each = weight(&resources, 0); + d.update_dict(root, |dict| { + dict.set(b"Kids".to_vec(), Object::Array(kids)); + dict.set(b"Resources".to_vec(), resources); + }) + .unwrap(); + let budget = 1 << 20; + let pages: Vec = PageTree::with_budget(&d, budget).collect(); + let copied: usize = pages.iter().filter_map(|p| p.dict.get(b"Resources")).map(|r| weight(r, 0)).sum(); + assert!(copied <= budget, "{copied} bytes of inherited values copied into {} pages", pages.len()); + assert_eq!(pages.len(), budget / each); + assert_eq!(super::pages(&d).len(), 200); + } + #[test] fn repeated_children_do_not_queue_resource_copies() { let mut d = doc(0); From a0e82da864aa65e9386678feafd2f84c01a9159b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:48:08 +0000 Subject: [PATCH 157/517] fix(render): bound the replacement text copied into redraws Each unmarked redraw of an /ActualText span's glyphs took its own copy of the replacement text. The work budget counts comparisons, not bytes, so a long replacement around one glyph followed by many redraws at the same place copied it once per redraw: gigabytes from a small page. Redraws on one page share 4 MiB of replacement text; past it a redraw keeps its own glyphs, as it does past the work budget. --- crates/render/src/text.rs | 44 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 45c87bfea7..11cde902f9 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -420,6 +420,15 @@ const MAX_CELLS: i64 = 4096; /// their unmarked redraws (the text reads twice, as it would without `/ActualText`). const MAX_REDRAW_WORK: usize = 1 << 22; +/// Most replacement text bytes the redraw dedupe in `TextDevice::finish` copies on one page. +const MAX_REDRAW_TEXT: usize = 1 << 22; + +#[cfg(test)] +thread_local! { + /// Replacement text bytes given to redraws on this thread. + static REDRAW_TEXT_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + fn cell(v: f32) -> i64 { // Glyph boxes are finite (`draw_glyph` and `merge` drop the rest); `as` saturates. (v / CELL).floor() as i64 @@ -564,6 +573,7 @@ impl TextDevice { redraws.sort_by_key(|(range, _)| range.start); let glyphs = std::mem::take(&mut self.glyphs); let mut redraws = redraws.into_iter().peekable(); + let mut text_left = MAX_REDRAW_TEXT; let mut i = 0; while let Some(g) = glyphs.get(i) { let Some((range, k)) = redraws.next_if(|(range, _)| range.start == i) else { @@ -572,7 +582,18 @@ impl TextDevice { continue; }; let run = glyphs.get(range.clone()).unwrap_or_default(); - let text = self.replaced.get(k).map(|r| r.text.clone()).unwrap_or_default(); + let replacement = self.replaced.get(k).map_or("", |r| r.text.as_str()); + // Each redraw takes its own copy of the replacement, and one long replacement can + // have many redraws: past the budget a redraw keeps its glyphs, as past the work budget. + let Some(left) = text_left.checked_sub(replacement.len()) else { + self.glyphs.extend_from_slice(run); + i = range.end.max(i + 1); + continue; + }; + text_left = left; + let text = replacement.to_owned(); + #[cfg(test)] + REDRAW_TEXT_BYTES.with(|c| c.set(c.get() + text.len())); if !text.chars().all(|c| c.is_control()) { match merge(run, text) { Some(glyph) => self.glyphs.push(glyph), @@ -1871,6 +1892,27 @@ mod tests { extract_page(pdf, page, &InterpreterSettings::default()).unwrap() } + #[test] + fn redraws_of_a_long_replacement_share_a_text_budget() { + let long = "0041".repeat(32 * 1024); + let redraws = 200; + let content = format!( + "BT /F1 20 Tf 1 0 0 1 100 150 Tm /Span <>> BDC (x) Tj EMC {} ET", + "1 0 0 1 100 150 Tm (x) Tj ".repeat(redraws) + ); + let pdf = marked_pages(&[&content]); + REDRAW_TEXT_BYTES.with(|c| c.set(0)); + let text = marked_text(&pdf, 0); + let given = REDRAW_TEXT_BYTES.with(|c| c.get()); + assert!(given <= MAX_REDRAW_TEXT, "{given} bytes of replacement text given to {redraws} redraws"); + assert!(text.plain_text().starts_with("AAAA")); + // A short replacement still covers its redraw. + let pdf = marked_pages(&["BT /F1 20 Tf 1 0 0 1 100 150 Tm /Span <> BDC (x) Tj EMC 1 0 0 1 100 150 Tm (x) Tj ET"]); + REDRAW_TEXT_BYTES.with(|c| c.set(0)); + assert_eq!(marked_text(&pdf, 0).plain_text(), "fi"); + assert_eq!(REDRAW_TEXT_BYTES.with(|c| c.get()), 2); + } + #[test] fn text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei() { // Everyday content both ways: a string with a byte above 0x7F, and a later word holding From 24701fe9591e93cf18662cc89d4f8f8d01a44fe3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:00:42 +0000 Subject: [PATCH 158/517] fix(print): list the source pages once per imposition page_form listed every page of the document to take the one it printed, so imposing P pages walked the page tree and copied its leaf dictionaries P times over: P^2 page copies. impose passes the list it already holds. A page index outside it is an error, not a panic. --- crates/print/src/lib.rs | 23 ++++++++++++++++++++--- crates/print/src/tests.rs | 11 +++++++++++ crates/print/src/tests/appearances.rs | 3 ++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 6839e5f6b0..5f81d1f93d 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -770,13 +770,16 @@ fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option, ) -> Result { - let page = pdfcraft_model::pages(doc).swap_remove(index); + let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; let crop = page.crop(doc); let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut body = if content == Content::FormFieldsOnly { Vec::new() } else { decoded_contents(doc, &page.dict) }; @@ -841,10 +844,24 @@ fn page_form( Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) } +#[cfg(test)] +thread_local! { + /// Pages listed by [`source_pages`] on this thread. + static PAGES_LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The source document's pages, for printing. +fn source_pages(doc: &Document) -> Vec { + let pages = pdfcraft_model::pages(doc); + #[cfg(test)] + PAGES_LISTED.with(|c| c.set(c.get() + pages.len())); + pages +} + /// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a /// printer or is the user's own copy); callers must check the print permission first. pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { - let pages = pdfcraft_model::pages(src); + let pages = source_pages(src); let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); let sheets = layout(&sizes, settings)?; let mut doc = src.clone(); @@ -861,7 +878,7 @@ pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError let form = match forms.get(&pl.page) { Some(f) => *f, None => { - let f = page_form(&mut doc, pl.page, settings.content, &mut promoted)?; + let f = page_form(&mut doc, &pages, pl.page, settings.content, &mut promoted)?; forms.insert(pl.page, f); f } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 99634f1aa1..ce736762e4 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -192,6 +192,17 @@ fn custom_pages_per_sheet_never_overfill_the_grid() { assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), 3); } +#[test] +fn imposing_lists_the_pages_once() { + let n = 150; + let doc = fixture(n); + PAGES_LISTED.with(|c| c.set(0)); + let out = impose(&doc, &settings((0..n).collect(), Layout::multiple(2))).unwrap(); + let listed = PAGES_LISTED.with(|c| c.get()); + assert!(listed <= 2 * n, "{listed} pages listed to print {n}"); + assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), n / 2); +} + #[test] fn booklets_pair_pages_for_folding() { let sizes = vec![(200.0, 300.0); 6]; diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs index 8d6f56ff6f..acf8ece6f1 100644 --- a/crates/print/src/tests/appearances.rs +++ b/crates/print/src/tests/appearances.rs @@ -147,7 +147,8 @@ fn promotion_preserves_streams_and_explicit_subtypes() { for subtype in [None, Some(Object::name("Form")), Some(Object::name("Image")), Some(Object::Null), Some(Object::Int(7))] { let (mut doc, ap, _) = widget_fixture(subtype.clone(), Some([2.0, 0.0, 0.0, 3.0, 5.0, 7.0])); let original = doc.get(ap); - let form = page_form(&mut doc, 0, Content::Document, &mut std::collections::HashMap::new()).unwrap(); + let pages = pdfcraft_model::pages(&doc); + let form = page_form(&mut doc, &pages, 0, Content::Document, &mut std::collections::HashMap::new()).unwrap(); assert_eq!(doc.get(ap), original, "original in the print clone"); let promoted = xobject(&doc, doc.get(form).as_dict().unwrap(), b"PCPrA0"); if subtype.is_some() { From 3be929978329592fff441e5815bdfca5f1bbc2aa Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:45:41 +0300 Subject: [PATCH 159/517] M7 PR #3000: wait for the asynchronous form-save fixture --- crates/ui-egui/tests/it/forms.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 230addd656..64440612ca 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -647,6 +647,7 @@ fn typing_and_saving_in_the_same_frame_saves_the_last_keystrokes() { let out = dir.join("saved.pdf"); let mut h = harness(); type_then_save_in_one_frame(&mut h, "name", "Browser", egui::Event::Text(" Form 123".into()), &out); + wait_for_file(&mut h, &out); assert_eq!(saved_value(&out, "name"), ["Browser Form 123"]); let _ = std::fs::remove_dir_all(&dir); } From 3a11f39ef0ef58169a10d95598df832e9a5ff319 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:47:29 +0000 Subject: [PATCH 160/517] chore(cos): allow the boxed dictionary key index under clippy The index is boxed so that it adds one pointer to every Dict, and so to every Object, rather than an inline map. --- crates/cos/src/object.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 6b6a4befe2..ee3c49a787 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -92,6 +92,8 @@ const INDEX_FROM: usize = 32; pub struct Dict { entries: Vec<(Name, Object)>, /// Key to position in `entries`, kept once the dictionary reaches [`INDEX_FROM`] entries. + /// Boxed so that every `Dict`, and so every `Object`, grows by one pointer, not by a map. + #[allow(clippy::box_collection)] index: Option>>, } From 735fb8e661f39293cd7417628ce6a541feb959bc Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:47:29 +0000 Subject: [PATCH 161/517] docs(filters, model): record the filter chain and inherited copy limits --- crates/filters/README.md | 2 +- crates/model/README.md | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/filters/README.md b/crates/filters/README.md index df64e4b7c0..fc5306e322 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -23,7 +23,7 @@ encode_flate(data) -> Vec // zlib, level 6 ``` - `chain` is applied first to last, as in a `/Filter` array. -- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. Decryption belongs to `crypt`. +- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. So does a chain longer than `MAX_CHAIN` (8) filters, before any stage runs. Decryption belongs to `crypt`. - `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding. - `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors. diff --git a/crates/model/README.md b/crates/model/README.md index bfc6e42e3b..09a0603913 100644 --- a/crates/model/README.md +++ b/crates/model/README.md @@ -17,6 +17,9 @@ L3/L4 crates need: The page-tree walk keeps one cursor per ancestor and shares inherited attribute owners. Repeated child references are skipped before copying attributes, with first depth-first occurrence order preserved. Returned page dictionaries still own their effective attributes. +A walk copies at most `MAX_INHERITED_COPIES` (1 GiB, approximate heap bytes) of inherited +values into the leaves; past it the walk stops, as it does past its leaf cap, and the pages +after that point are not listed. `organize`, `annot` and `forms` still carry their own small walkers; they move here as the model grows (ADR-0004). From caf189522fac9d239f4897e4884032a4f2c81f87 Mon Sep 17 00:00:00 2001 From: Hitesh Date: Thu, 22 Oct 2026 23:18:01 +0530 Subject: [PATCH 162/517] fix(edit): a refused delete keeps the object selection Review on #4111: Delete cleared the selection before the edit ran, so a delete the engine refuses (text used as a clipping path) left the document as it was but dropped what the user had selected. The selection now stays until the edit applies; `ObjectSelection::prepare` already clears it when the document changes and keeps it when an edit is refused. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/object_ui.rs | 3 ++- crates/ui-egui/tests/it/editing.rs | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index 7f81f76a24..04e37feeea 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -396,8 +396,9 @@ pub(crate) fn delete_keys(ctx: &egui::Context, view: &mut DocView) { if !ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace)) { return; } + // The selection stays until the edit applies: `prepare` clears it when the document changes, + // and a refused delete (text used as a clipping path) leaves it selected. view.pending_edit = delete_edit(page, view.objects.selected.iter().map(|o| o.target).collect()); - view.objects.clear(); } /// The edit that deletes `targets` (indexes into one inventory of `page`) as one undo step. diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 38c4ddbdbb..4a35390c90 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -4219,6 +4219,30 @@ fn an_ordinary_paragraph_drag_refusal_never_rewrites_the_text() { assert!(h.state().toast.as_ref().unwrap().0.contains("Text used as a clipping path cannot be moved with this selection.")); } +/// A delete the engine refuses (text used as a clipping path) leaves the document as it was and +/// keeps the selection, so the user can still move or nudge what they had selected. +#[test] +fn a_refused_delete_keeps_the_object_selection() { + let content = "q BT 4 Tr /F1 12 Tf 60 320 Td (Clipping text) Tj ET Q"; + let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0); + let id = h.state().views[0].id; + let (generation, bytes) = { + let doc = h.state().session.get(id).unwrap(); + (doc.edit_generation(), doc.bytes.clone()) + }; + // An area selection from empty page space around the paragraph. + let (center, _) = paragraph_drag_points(&h, "Clipping text", [0.0, 0.0]); + drag(&mut h, center - egui::vec2(60.0, 25.0), center + egui::vec2(60.0, 25.0)); + h.get_by_label("1 object selected"); + h.key_press(Key::Delete); + h.run_steps(4); + let doc = h.state().session.get(id).unwrap(); + assert_eq!((doc.edit_generation(), &doc.bytes), (generation, &bytes), "the refused delete changed nothing"); + assert!(doc.can_undo().is_none()); + assert!(h.state().toast.is_some(), "the refusal is reported"); + h.get_by_label("1 object selected"); +} + #[test] fn an_ordinary_paragraph_drag_cannot_reuse_an_index_after_an_edit() { let content = "BT /F1 12 Tf 60 120 Td (Discard) Tj ET BT /F1 12 Tf 60 320 Td (Moving alpha) Tj ET BT /F1 12 Tf 60 220 Td (Keep here) Tj ET"; @@ -6002,6 +6026,7 @@ fn a_paragraph_selected_by_its_frame_deletes_with_one_key() { press(&mut h, Modifiers::NONE, key); assert!(blocks(&h).is_empty(), "{key:?} deletes the whole paragraph: {:?}", blocks(&h)); assert!(h.state().views[0].line_editor.is_none(), "no empty editor is left behind"); + assert!(h.query_all_by_label_contains("object selected").next().is_none(), "the deleted paragraph is no longer selected"); assert_eq!(h.state().session.get(id).unwrap().text_blocks(1)[0].text, "Page 2", "the next page keeps its text"); assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 2, "Delete removed the paragraph, not a page"); assert!(h.state_mut().execute("edit.undo")); From 93436d41b050f7a199efdea1f5d2b84f0348a7de Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:43:04 +0000 Subject: [PATCH 163/517] fix(xfdf): escape paired line-ending names in exported XFDF The head and tail attributes were written from the annotation's /LE names without XML escaping, so a name holding a quote or angle bracket could add attributes or elements, including field values, to the exported file. --- crates/xfdf/src/lib.rs | 2 +- crates/xfdf/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 219b0de81b..bc3ea01f40 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -354,7 +354,7 @@ fn xfdf_annots(doc: &Document, out: &mut String) { && le.len() == 2 { let name = |o: &Object| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned()).unwrap_or_else(|| "None".into()); - let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", name(&le[0]), name(&le[1])); + let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", esc(&name(&le[0])), esc(&name(&le[1]))); } // A callout: its leader path, text-box inset, intent and (single-name) line ending. let cl = nums_of(doc, d.get(b"CL")); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index c98fab06ac..a93f70c70e 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1758,3 +1758,26 @@ fn an_imported_redaction_rect_with_a_non_number_is_not_used() { let mark = imported_redact_mark(fdf); assert!(mark.get(b"QuadPoints").is_none(), "quads were drawn from a malformed /Rect: {mark:?}"); } + +/// Line-ending names are attribute values: a name holding a quote or angle bracket cannot add +/// attributes or elements to the exported XFDF. +#[test] +fn xfdf_line_ending_names_are_escaped() { + let mut doc = blank(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let hostile = b"a\" tail=\"x\">pwned Date: Thu, 22 Oct 2026 15:43:57 +0000 Subject: [PATCH 164/517] fix(xfdf): bound what an FDF import copies out of repeated references The FDF field walker and the comment copier followed every reference again each time it appeared, so a field tree or comment whose kids or properties repeat one reference level after level expanded a small file into millions of rows or values before the depth limit stopped it. Both now skip a reference back to an object they are inside of, and share one budget per import of values and text; past it the import is refused. --- crates/xfdf/src/lib.rs | 117 +++++++++++++++++++++++++++++++++------ crates/xfdf/src/tests.rs | 61 ++++++++++++++++++++ 2 files changed, 161 insertions(+), 17 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index bc3ea01f40..99470b58a1 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1198,18 +1198,66 @@ fn find_by_name(doc: &Document, nm: &str) -> Option { None } -/// Copy an object out of another document, resolving references (FDF objects are small). -fn deep(src: &Document, o: &Object, depth: usize) -> Object { +/// What one FDF import may copy out of the file. A field tree or comment that repeats a reference +/// level after level would otherwise expand a small file into millions of values. +const MAX_FDF_IMPORT_VALUES: usize = 1_000_000; + +struct ImportBudget { + values: usize, + bytes: usize, +} + +impl ImportBudget { + fn new() -> Self { + Self { values: MAX_FDF_IMPORT_VALUES, bytes: MAX_FDF_BYTES } + } + + fn spend(&mut self, values: usize, bytes: usize) -> Result<(), DataError> { + match (self.values.checked_sub(values), self.bytes.checked_sub(bytes)) { + (Some(v), Some(b)) => { + (self.values, self.bytes) = (v, b); + Ok(()) + } + _ => Err(DataError::Malformed(format!( + "the file expands to more than {MAX_FDF_IMPORT_VALUES} values or {} MiB of text", + MAX_FDF_BYTES >> 20 + ))), + } + } +} + +/// Copy an object out of another document, resolving references. A reference back to an object +/// being copied becomes null. +fn deep(src: &Document, o: &Object, depth: usize, path: &mut Vec, budget: &mut ImportBudget) -> Result { + budget.spend(1, 0)?; if depth > 16 { - return Object::Null; + return Ok(Object::Null); } - match o { - Object::Ref(r) => deep(src, &src.get(*r), depth + 1), - Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, x, depth + 1)).collect()), - Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), deep(src, v, depth + 1))).collect()), + Ok(match o { + Object::Ref(r) => { + if path.contains(r) { + return Ok(Object::Null); + } + path.push(*r); + let copy = deep(src, &src.get(*r), depth + 1, path, budget); + path.pop(); + copy? + } + Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, x, depth + 1, path, budget)).collect::>()?), + Object::Dict(d) => { + Object::Dict(d.iter().map(|(k, v)| Ok((k.clone(), deep(src, v, depth + 1, path, budget)?))).collect::>()?) + } Object::Stream(_) => Object::Null, + Object::String(s) => { + budget.spend(0, s.bytes.len())?; + o.clone() + } + Object::Name(n) => { + budget.spend(0, n.len())?; + o.clone() + } other => other.clone(), - } + }) } fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec)>, depth: usize) -> Result<(), DataError> { @@ -1233,27 +1281,59 @@ fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec)>, depth: usize) { +/// Read an FDF field tree. A field listed again beneath itself is skipped. +fn walk_fdf( + fdf: &Document, + list: &[Object], + prefix: &str, + out: &mut Vec<(String, Vec)>, + depth: usize, + path: &mut Vec, + budget: &mut ImportBudget, +) -> Result<(), DataError> { if depth > 32 { - return; + return Ok(()); } for f in list { - let Some(d) = fdf.resolve(f).as_dict().cloned() else { continue }; + budget.spend(1, 0)?; + let held; + let field = match f { + Object::Ref(r) if path.contains(r) => continue, + Object::Ref(r) => { + held = fdf.get(*r); + &*held + } + direct => direct, + }; + let Some(d) = field.as_dict() else { continue }; let Some(t) = d.get(b"T").and_then(|t| fdf.resolve(t).as_string().map(|s| s.to_text())) else { continue }; let full = if prefix.is_empty() { t } else { format!("{prefix}.{t}") }; + budget.spend(0, full.len())?; if let Some(v) = d.get(b"V").map(|v| fdf.resolve(v)) { let vals: Vec = match &*v { Object::String(s) => vec![s.to_text()], Object::Name(n) => vec![pdfcraft_forms::name_text(n)], - Object::Array(a) => a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect(), + Object::Array(a) => { + budget.spend(a.len(), 0)?; + a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect() + } _ => Vec::new(), }; + budget.spend(vals.len(), vals.iter().map(String::len).fold(0, usize::saturating_add))?; out.push((full.clone(), vals)); } - if let Some(kids) = d.get(b"Kids").map(|k| fdf.resolve(k)).and_then(|k| k.as_array().cloned()) { - walk_fdf(fdf, &kids, &full, out, depth + 1); + if let Some(kids) = d.get(b"Kids").map(|k| fdf.resolve(k)) + && let Some(kids) = kids.as_array() + { + let entered = f.as_ref().map(|r| path.push(r)).is_some(); + let walked = walk_fdf(fdf, kids, &full, out, depth + 1, path, budget); + if entered { + path.pop(); + } + walked?; } } + Ok(()) } fn fdf_body(fdf: &Document) -> Result { @@ -1272,8 +1352,9 @@ pub fn data_values(bytes: &[u8]) -> Result)>, DataError let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?; let body = fdf_body(&fdf)?; let mut values = Vec::new(); + let mut budget = ImportBudget::new(); if let Some(list) = body.get(b"Fields").map(|f| fdf.resolve(f)).and_then(|f| f.as_array().cloned()) { - walk_fdf(&fdf, &list, "", &mut values, 0); + walk_fdf(&fdf, &list, "", &mut values, 0, &mut Vec::new(), &mut budget)?; } return Ok(values); } @@ -1346,10 +1427,11 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?; let body = fdf_body(&fdf)?; let mut report = Report::default(); + let mut budget = ImportBudget::new(); // Fields (with /Kids for hierarchical names). let mut values = Vec::new(); if let Some(list) = body.get(b"Fields").map(|f| fdf.resolve(f)).and_then(|f| f.as_array().cloned()) { - walk_fdf(&fdf, &list, "", &mut values, 0); + walk_fdf(&fdf, &list, "", &mut values, 0, &mut Vec::new(), &mut budget)?; } // Comments: parents first, then replies (whose /IRT needs the parent's new reference). let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); @@ -1360,7 +1442,8 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { // The parent is another comment: link it below rather than copying it in. let irt = src.get(b"IRT").cloned(); src.remove(b"IRT"); - let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0) else { continue }; + let mut path: Vec = a.as_ref().into_iter().collect(); + let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0, &mut path, &mut budget)? else { continue }; let Some(page) = d.get(b"Page").and_then(Object::as_int).and_then(|p| usize::try_from(p).ok()).and_then(|p| pages.get(p).copied()) else { continue; }; diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index a93f70c70e..cf9095bf32 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1759,6 +1759,67 @@ fn an_imported_redaction_rect_with_a_non_number_is_not_used() { assert!(mark.get(b"QuadPoints").is_none(), "quads were drawn from a malformed /Rect: {mark:?}"); } +/// An FDF body with `body` as its `/FDF` dictionary contents and `objects` (numbered from 2). +fn fdf_with(body: &str, objects: &[String]) -> Vec { + let mut out = format!("%FDF-1.2\n1 0 obj\n<< /FDF << {body} >> >>\nendobj\n"); + for (i, o) in objects.iter().enumerate() { + out.push_str(&format!("{} 0 obj\n{o}\nendobj\n", i + 2)); + } + out.push_str("trailer\n<< /Root 1 0 R >>\n%%EOF\n"); + out.into_bytes() +} + +/// A field that lists itself as a kid is read once. +#[test] +fn an_fdf_field_listing_itself_is_read_once() { + let cyclic = fdf_with("/Fields [2 0 R]", &["<< /T (a) /V (x) /Kids [2 0 R] >>".into()]); + assert_eq!(data_values(&cyclic).unwrap(), vec![("a".to_string(), vec!["x".to_string()])]); +} + +/// A field tree whose `/Kids` repeat one subtree level after level is refused rather than +/// expanded into millions of rows. +#[test] +fn fdf_field_trees_that_repeat_references_are_bounded() { + let levels = 19; + let mut objects: Vec = (0..levels).map(|i| format!("<< /T (f{i}) /V (x) /Kids [{n} 0 R {n} 0 R] >>", n = i + 3)).collect(); + objects.push("<< /T (leaf) /V (x) >>".into()); + let shared = fdf_with("/Fields [2 0 R]", &objects); + let read = data_values(&shared); + assert!(read.is_err(), "{} rows", read.map_or(0, |v| v.len())); + assert!(matches!(import(&mut blank(), &shared), Err(DataError::Malformed(_)))); +} + +fn fdf_note(data: &str) -> String { + format!("<< /Subtype /Text /Rect [0 0 9 9] /NM (n) /Page 0 /PCData {data} >>") +} + +/// A comment property that refers back to the comment is copied once, not nested level after +/// level. +#[test] +fn an_fdf_comment_referring_to_itself_is_copied_once() { + let cyclic = fdf_with("/Annots [2 0 R]", &[fdf_note("2 0 R")]); + let mut dst = blank(); + assert_eq!(import(&mut dst, &cyclic).unwrap().comments, 1); + let placed = find_by_name(&dst, "n").unwrap(); + let data = dst.get(placed).as_dict().unwrap().get(b"PCData").cloned(); + assert!(!matches!(data, Some(Object::Dict(_))), "{data:?}"); +} + +/// A comment whose properties repeat one reference level after level is refused rather than +/// copied into millions of values. +#[test] +fn fdf_comment_graphs_that_repeat_references_are_bounded() { + let note = fdf_note; + let width = 6; + let mut objects = vec![note("3 0 R")]; + for i in 0..8 { + objects.push(format!("[{}]", vec![format!("{} 0 R", i + 4); width].join(" "))); + } + objects.push("(leaf)".into()); + let shared = fdf_with("/Annots [2 0 R]", &objects); + assert!(matches!(import(&mut blank(), &shared), Err(DataError::Malformed(_)))); +} + /// Line-ending names are attribute values: a name holding a quote or angle bracket cannot add /// attributes or elements to the exported XFDF. #[test] From ae791d667bfaf2785979eaac34b45502dc66c320 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:47:11 +0000 Subject: [PATCH 165/517] fix(sign): a time-stamp response must carry the requested nonce and policy parse_response compared only the digest algorithm and imprint with the query, and the token parser dropped the TSTInfo nonce. A genuine token for the same imprint from another request or under another policy was accepted. The token keeps its nonce, and a requested nonce or policy must match. --- crates/sign/src/timestamp.rs | 13 ++++++++++++- crates/sign/tests/it/pdf.rs | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/sign/src/timestamp.rs b/crates/sign/src/timestamp.rs index 58eda4f049..0eba6bb72c 100644 --- a/crates/sign/src/timestamp.rs +++ b/crates/sign/src/timestamp.rs @@ -62,6 +62,8 @@ pub struct TimestampToken { pub policy: String, pub serial: Vec, pub gen_time: Time, + /// The TSTInfo nonce, when it has one that fits a `u64` (the size requests use). + pub nonce: Option, } impl TimestampToken { @@ -140,6 +142,9 @@ pub fn parse_token(raw: &[u8]) -> Result { .to_vec(); let serial = fields.get(3).ok_or_else(|| bad("TSTInfo has no serial number"))?.uint_bytes().to_vec(); let gen_time = fields.get(4).ok_or_else(|| bad("TSTInfo has no generation time"))?.time()?; + // The nonce is the only INTEGER after genTime (RFC 3161 §2.4.2): accuracy is a SEQUENCE and + // ordering a BOOLEAN. + let nonce = fields.iter().skip(5).find(|f| f.tag == tag::INTEGER).and_then(|f| f.u64().ok()); // The signed messageDigest uses the SignerInfo's digest algorithm, which need not be the // imprint's (RFC 5652 §5.4). let content_digest = token.signer.digest.digest(&[info]); @@ -151,7 +156,7 @@ pub fn parse_token(raw: &[u8]) -> Result { if !token.verify_signature(cert, &content_digest)? { return Err(bad("TSA signature is invalid")); } - Ok(TimestampToken { raw: raw.to_vec(), digest, imprint, policy, serial, gen_time }) + Ok(TimestampToken { raw: raw.to_vec(), digest, imprint, policy, serial, gen_time, nonce }) } /// Parse and validate a `TimeStampResp`, including the CMS signature and message imprint. @@ -170,6 +175,12 @@ pub fn parse_response(bytes: &[u8], query: &TimestampQuery) -> Result, policy: Option<&str>| { + let mut q = TimestampQuery::new(alg, alg.digest(&[b"document"])).unwrap(); + (q.nonce, q.policy) = (nonce, policy.map(str::to_string)); + q + }; + let answer = |q: &TimestampQuery| respond(&tsa.key, &tsa.certificate, &tsa.chain, alg, q, "1.2.3.4", time, 9).unwrap(); + let with_nonce = answer(&query(Some(42), None)); + assert!(parse_response(&with_nonce, &query(Some(42), Some("1.2.3.4"))).is_ok()); + assert!(parse_response(&with_nonce, &query(None, None)).is_ok()); + assert!(parse_response(&with_nonce, &query(Some(43), None)).is_err(), "another request's nonce"); + assert!(parse_response(&with_nonce, &query(Some(42), Some("1.2.3.5"))).is_err(), "another policy"); + let without_nonce = answer(&query(None, None)); + assert!(parse_response(&without_nonce, &query(Some(42), None)).is_err(), "no nonce in the token"); +} + #[cfg(windows)] #[test] fn windows_store_enumeration_and_missing_identity() { From 0a30b4e08107ad1b5809afc83c1f9790a6ef26f8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:01:27 +0000 Subject: [PATCH 166/517] fix(preflight): read content colour one operator at a time content_colour parsed each content stream into a full list of operators before looking at any of them, so a small compressed stream of millions of tiny operators became gigabytes of parsed operators in memory. The content parser's loop is now an iterator (pdfcraft_content::ops), which parse collects; preflight reads it one operator at a time and stops once gray, RGB and CMYK are all found. --- crates/content/src/lib.rs | 197 +++++++++++++++++++++------------- crates/preflight/src/lib.rs | 16 ++- crates/preflight/src/tests.rs | 13 +++ 3 files changed, 151 insertions(+), 75 deletions(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index eb126ffa21..3432f7ff32 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -79,90 +79,143 @@ pub fn parse(data: &[u8]) -> Parsed { parse_pieces(data, &[]) } +/// The operators of a (decoded) content stream, read one at a time, as [`parse`] reads them. A +/// caller that stops early or keeps only a summary never holds every operator of the stream. +pub fn ops(data: &[u8]) -> Ops<'_> { + Ops::new(data, &[]) +} + /// Parse `data`, recording which of the piece boundaries `ends` (as in [`Pieces`]) a comment /// crosses. fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed { - let mut out = Parsed { comment_edges: vec![(false, false); ends.len()], ..Parsed::default() }; - let mut boundary = 0; - let mut lx = Lexer::new(data, 0); - let mut operands: Vec = Vec::new(); - let mut start: Option = None; - loop { - let trivia = lx.pos; - lx.skip_ws(); - // Only the whitespace the lexer skips here holds top-level comments: a `%` inside a - // string, a name, an array or dictionary operand or inline image data is not one. - if boundary < ends.len() { - comment_edges(data, trivia..lx.pos, ends, &mut boundary, &mut out.comment_edges); + let mut reader = Ops::new(data, ends); + let ops = reader.by_ref().collect(); + Parsed { ops, skipped: reader.skipped, comment_edges: reader.comment_edges } +} + +/// The iterator [`ops`] returns. +pub struct Ops<'a> { + data: &'a [u8], + lx: Lexer<'a>, + operands: Vec, + start: Option, + skipped: usize, + ends: &'a [usize], + boundary: usize, + comment_edges: Vec<(bool, bool)>, +} + +impl<'a> Ops<'a> { + fn new(data: &'a [u8], ends: &'a [usize]) -> Self { + Ops { + data, + lx: Lexer::new(data, 0), + operands: Vec::new(), + start: None, + skipped: 0, + ends, + boundary: 0, + comment_edges: vec![(false, false); ends.len()], } - let Some(&b) = data.get(lx.pos) else { break }; - let at = lx.pos; - let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit(); - if operand_start { - match lx.object() { - Ok(o) => { + } + + /// Bytes read so far that could not be read as operands or operators. + pub fn skipped(&self) -> usize { + self.skipped + } +} + +impl Iterator for Ops<'_> { + type Item = Op; + + fn next(&mut self) -> Option { + let Ops { data, lx, operands, start, skipped, ends, boundary, comment_edges: edges } = self; + let (data, ends): (&[u8], &[usize]) = (data, ends); + loop { + let trivia = lx.pos; + lx.skip_ws(); + // Only the whitespace the lexer skips here holds top-level comments: a `%` inside a + // string, a name, an array or dictionary operand or inline image data is not one. + if *boundary < ends.len() { + comment_edges(data, trivia..lx.pos, ends, boundary, edges); + } + let Some(&b) = data.get(lx.pos) else { + // Operands left without an operator. + *skipped += operands.len(); + operands.clear(); + return None; + }; + let at = lx.pos; + let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit(); + if operand_start { + match lx.object() { + Ok(o) => { + start.get_or_insert(at); + operands.push(o); + } + Err(_) if b == b'(' => { + // An unterminated string runs to the end of the stream. + *skipped += data.len() - at; + lx.pos = data.len(); + } + Err(_) => { + // Skip the offending byte and carry on. + lx.pos = at + 1; + *skipped += 1; + } + } + continue; + } + if is_delim(b) { + // A stray `)`, `>`, `]`, `{`, `}`. + lx.pos += 1; + *skipped += 1; + continue; + } + let kw = lx.token(); + if kw.is_empty() { + lx.pos = at + 1; + *skipped += 1; + continue; + } + match kw { + b"true" => { start.get_or_insert(at); - operands.push(o); + operands.push(Object::Bool(true)); } - Err(_) if b == b'(' => { - // An unterminated string runs to the end of the stream. - out.skipped += data.len() - at; - lx.pos = data.len(); + b"false" => { + start.get_or_insert(at); + operands.push(Object::Bool(false)); } - Err(_) => { - // Skip the offending byte and carry on. - lx.pos = at + 1; - out.skipped += 1; + b"null" => { + start.get_or_insert(at); + operands.push(Object::Null); } - } - continue; - } - if is_delim(b) { - // A stray `)`, `>`, `]`, `{`, `}`. - lx.pos += 1; - out.skipped += 1; - continue; - } - let kw = lx.token(); - if kw.is_empty() { - lx.pos = at + 1; - out.skipped += 1; - continue; - } - match kw { - b"true" => { - start.get_or_insert(at); - operands.push(Object::Bool(true)); - } - b"false" => { - start.get_or_insert(at); - operands.push(Object::Bool(false)); - } - b"null" => { - start.get_or_insert(at); - operands.push(Object::Null); - } - b"BI" => match inline_image(data, lx.pos) { - Ok((dict, img, end)) => { - lx.pos = end; - out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end }); - operands.clear(); + b"BI" => match inline_image(data, lx.pos) { + Ok((dict, img, end)) => { + lx.pos = end; + operands.clear(); + return Some(Op { + op: b"BI".to_vec(), + operands: Vec::new(), + inline: Some((dict, img)), + span: start.take().unwrap_or(at)..end, + }); + } + Err(end) => { + *skipped += end.saturating_sub(at); + lx.pos = end; + *start = None; + operands.clear(); + } + }, + _ => { + let s = start.take().unwrap_or(at); + return Some(Op { op: kw.to_vec(), operands: std::mem::take(operands), inline: None, span: s..lx.pos }); } - Err(end) => { - out.skipped += end.saturating_sub(at); - lx.pos = end; - start = None; - operands.clear(); - } - }, - _ => { - let s = start.take().unwrap_or(at); - out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos }); } } } - out.skipped += operands.len(); - out } /// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 2cda29fea1..4e5f7eb2a1 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -339,8 +339,17 @@ fn is_content(d: &Dict) -> bool { /// The device colour a content stream sets. It is read operator by operator, so the bytes of a /// string, a comment or an inline image are never taken for an operator, and an operator right /// before a delimiter (`1 k/GS0 gs`) is still found. -fn content_colour(data: &[u8], used: &mut DeviceColour) { - for op in pdfcraft_content::parse(data).ops { +/// +/// Operators are read one at a time and dropped, so a stream of millions of tiny operators never +/// becomes millions of parsed ones in memory, and reading stops once all three spaces are set. +/// Returns how many operators it read. +fn content_colour(data: &[u8], used: &mut DeviceColour) -> usize { + let mut read = 0; + for op in pdfcraft_content::ops(data) { + if used.gray && used.rgb && used.cmyk { + break; + } + read += 1; match op.op.as_slice() { b"g" | b"G" => used.gray = true, b"rg" | b"RG" => used.rgb = true, @@ -366,6 +375,7 @@ fn content_colour(data: &[u8], used: &mut DeviceColour) { _ => {} } } + read } /// Whether any content or object uses a device colour space. @@ -400,7 +410,7 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) && (referenced.contains(r) || is_content(&s.dict)) && let Ok(data) = s.decoded() { - content_colour(&data, &mut used); + let _ = content_colour(&data, &mut used); } } used diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 80b1c63c49..290c7695a0 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -417,3 +417,16 @@ fn oversized_xmp_is_refused_while_decoding() { assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress"); assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding"); } + +/// Content is read one operator at a time and no further than it needs: once gray, RGB and CMYK +/// are all set, the rest of the stream is not read. +#[test] +fn content_colour_reads_operators_one_at_a_time_and_stops_when_all_are_found() { + let mut data = b"0 g 0 0 0 rg 0 0 0 0 k\n".to_vec(); + data.extend(b"n\n".repeat(100_000)); + let mut used = DeviceColour::default(); + let read = content_colour(&data, &mut used); + assert!(used.gray && used.rgb && used.cmyk); + assert_eq!(read, 3); +} + From adab150a4558722503506617de545563308c2e2e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:01:51 +0000 Subject: [PATCH 167/517] fix(preflight): find annotation pages from one pass over the pages verify found each annotation's page by copying and searching every page's /Annots array again for each annotation, so a page with many annotations cost quadratic time. The annotation-to-page map is built once (the first page that lists an annotation keeps it, as before). --- crates/preflight/src/lib.rs | 31 ++++++++++++++++++++----------- crates/preflight/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 4e5f7eb2a1..731234aa6b 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -416,6 +416,12 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) used } +#[cfg(test)] +thread_local! { + /// Page `/Annots` entries read while matching annotations to their pages. + static ANNOT_ENTRIES_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Check `doc` against `level`. pub fn verify(doc: &Document, level: Level) -> Vec { let mut out = Vec::new(); @@ -486,17 +492,20 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } // Pages: annotations and page actions. let pages = pdfcraft_model::pages(doc); - let page_of = |r: ObjRef| { - pages.iter().position(|p| { - p.dict - .get(b"Annots") - .map(|a| doc.resolve(a)) - .and_then(|a| a.as_array().cloned()) - .unwrap_or_default() - .iter() - .any(|x| x.as_ref() == Some(r)) - }) - }; + // Each annotation's page: the first page that lists it. + let mut annot_pages: std::collections::HashMap = std::collections::HashMap::new(); + for (i, p) in pages.iter().enumerate() { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + #[cfg(test)] + ANNOT_ENTRIES_READ.with(|n| n.set(n.get() + a.len())); + for r in a.iter().filter_map(Object::as_ref) { + annot_pages.entry(r).or_insert(i); + } + } + } + let page_of = |r: ObjRef| annot_pages.get(&r).copied(); for (i, p) in pages.iter().enumerate() { if p.dict.get(b"AA").is_some() { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 290c7695a0..a44d47de15 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -430,3 +430,21 @@ fn content_colour_reads_operators_one_at_a_time_and_stops_when_all_are_found() { assert_eq!(read, 3); } +/// Each annotation's page is found from one pass over the pages' `/Annots`, not by searching +/// every page's list again for each annotation. +#[test] +fn annotation_pages_are_found_in_one_pass() { + let n = 2_000; + let annots: Vec = (0..n).map(|i| format!("{} 0 R", i + 4)).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", annots.join(" ")); + let mut objs = + vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), page]; + objs.extend((0..n).map(|_| "<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R >>".to_string())); + let refs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let doc = build(&refs, ""); + ANNOT_ENTRIES_READ.with(|c| c.set(0)); + let issues = verify(&doc, Level::A2b); + assert!(issues.iter().any(|i| i.clause == "6.3.2" && i.page == Some(0)), "{issues:#?}"); + let read = ANNOT_ENTRIES_READ.with(std::cell::Cell::get); + assert!(read <= 2 * n, "{read} entries read for {n} annotations"); +} From 90982ba9a86233db45e674baea493fc26196151f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:03:50 +0000 Subject: [PATCH 168/517] fix(preflight): check the actions an allowed action chains to Conversion and verification looked only at an action's own /S. An allowed action (a GoTo, say) whose /Next held a JavaScript or other forbidden action written directly inside it was kept, and verification never looked at actions written directly in a catalog /OpenAction or an annotation /A. Both now follow /Next through the actions written inside an action (an indirect /Next is an object of its own, checked as one), within a bound, and conversion removes the whole chain. --- crates/preflight/src/lib.rs | 63 +++++++++++++++++++++++++++-------- crates/preflight/src/tests.rs | 26 +++++++++++++++ 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 731234aa6b..905ba8cbf3 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -515,12 +515,14 @@ pub fn verify(doc: &Document, level: Level) -> Vec { let Some(d) = dict_of(o) else { continue }; let ty = d.name(b"Type"); let sub = d.name(b"Subtype"); - // Actions anywhere (structure elements also have /S: their role). - if let Some(s) = d.name(b"S") - && FORBIDDEN_ACTIONS.contains(&s) - && is_action(d) - { - issue("6.5.1", "A {} action isn't allowed", Some(String::from_utf8_lossy(s).into_owned()), None, true); + // Actions anywhere (structure elements also have /S: their role), with the actions they + // chain to and those written directly in an /A or /OpenAction. + let own = (d.get(b"S").is_some() && is_action(d)).then_some(d); + let direct = [b"A".as_slice(), b"OpenAction"].map(|k| d.get(k).and_then(|a| a.as_dict())); + for a in [own].into_iter().chain(direct).flatten() { + if let Some(s) = chain_forbidden(a) { + issue("6.5.1", "A {} action isn't allowed", Some(s), None, true); + } } // Annotations. if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) { @@ -652,10 +654,39 @@ fn is_action(d: &Dict) -> bool { } } -/// Whether `o` is (or refers to) a forbidden action, or to nothing. +/// How many actions one action and the `/Next` actions written inside it may hold before the +/// whole chain counts as forbidden, since it can't be checked. +const MAX_ACTION_CHAIN: usize = 1024; + +/// The first forbidden action type in the action `a` or in the actions written directly in its +/// `/Next` (ISO 32000-2 12.6.2: a dictionary or an array of them). An action `/Next` refers to +/// is an object of its own, checked and removed as one. +fn chain_forbidden(a: &Dict) -> Option { + let mut stack = vec![a]; + let mut read = 0; + while let Some(d) = stack.pop() { + read += 1; + if read > MAX_ACTION_CHAIN { + return Some("too deeply chained".into()); + } + if let Some(s) = d.name(b"S") + && FORBIDDEN_ACTIONS.contains(&s) + { + return Some(String::from_utf8_lossy(s).into_owned()); + } + match d.get(b"Next") { + Some(Object::Dict(next)) => stack.push(next), + Some(Object::Array(list)) => stack.extend(list.iter().filter_map(Object::as_dict)), + _ => {} + } + } + None +} + +/// Whether `o` is (or refers to) a forbidden action or one chaining to it, or to nothing. fn forbidden(doc: &Document, o: &Object) -> bool { match &*doc.resolve(o) { - Object::Dict(a) => a.name(b"S").is_some_and(|s| FORBIDDEN_ACTIONS.contains(&s)), + Object::Dict(a) => chain_forbidden(a).is_some(), Object::Null => true, _ => false, } @@ -801,14 +832,20 @@ pub fn convert(doc: &mut Document, level: Level) -> Result>"); + let open_action = format!("<< /Type /Catalog /Pages 2 0 R /OpenAction << /S /GoTo /D [3 0 R /Fit] /Next {js} >> >>"); + let goto = format!("<< /Type /Action /S /GoTo /D [3 0 R /Fit] /Next [{js}] >>"); + let cases = [ + build(&[&open_action, pages, page, &link("<< /S /GoTo /D [3 0 R /Fit] >>")], ""), + build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link("5 0 R"), &goto], ""), + build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link(&format!("<< /S /GoTo /D [3 0 R /Fit] /Next {js} >>"))], ""), + ]; + for (i, mut doc) in cases.into_iter().enumerate() { + let found = messages(&verify(&doc, Level::A2b)).contains(&"A JavaScript action isn't allowed"); + assert!(found, "case {i}: {:#?}", verify(&doc, Level::A2b)); + let r = convert(&mut doc, Level::A2b).unwrap(); + assert!(!r.remaining.iter().any(|x| x.clause == "6.5.1"), "case {i}: {:#?}", r.remaining); + let plain = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }; + let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap(); + assert!(!bytes.windows(11).any(|w| w == b"/JavaScript"), "case {i}: {}", String::from_utf8_lossy(&bytes)); + } +} + /// Each annotation's page is found from one pass over the pages' `/Annots`, not by searching /// every page's list again for each annotation. #[test] From 467c8556ec655be08e5b68b1f35ffbbc974415f7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:08:25 +0000 Subject: [PATCH 169/517] fix(engine): encode each image once for Office export Office export encoded and kept a separate copy of an image for every place a page drew it, so a short content stream repeating Do on one large image multiplied it into gigabytes before the writers' limits applied. Each image object is now encoded once and its bytes shared (pdfcraft_export::Image holds an Arc<[u8]>), distinct pictures share a 512 MiB budget whose overflow lists the page as truncated, and RTF, which leaves pictures out, prepares none. --- crates/engine/src/compare.rs | 50 +++++++++++++++---- crates/engine/src/tests.rs | 2 +- crates/engine/tests/it/main.rs | 1 + .../engine/tests/it/office_export_images.rs | 43 ++++++++++++++++ crates/export/src/lib.rs | 9 ++-- crates/export/src/pptx.rs | 10 +++- 6 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 crates/engine/tests/it/office_export_images.rs diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index c23db91f1f..095e666c14 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -1,8 +1,11 @@ //! Compare files: the text differences between two open documents, a report, and the //! differences marked as comments in the newer one. +use std::collections::HashMap; use std::sync::Arc; +use pdfcraft_cos::ObjRef; + pub use pdfcraft_compare::{Change, Comparison, Kind, Side}; // How far Office export reads into form XObjects; `OfficeExport::truncated_pages` lists the pages // that draw more. @@ -10,6 +13,10 @@ pub use pdfcraft_edit::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape}; +/// Most bytes of encoded pictures one Office export prepares, counting each image object once +/// (the Word and PowerPoint writers keep at most 512 MiB of them as well). +pub const MAX_EXPORT_IMAGE_BYTES: usize = 512 << 20; + /// Acrobat's compare colours: replaced blue, inserted green, deleted red. pub fn colour(kind: Kind) -> crate::Rgb { match kind { @@ -163,23 +170,32 @@ pub struct OfficeExport { /// The exported file. pub bytes: Vec, /// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them - /// than [`MAX_FORM_VISITS`]: export stops reading there to stay bounded on hostile files, so - /// the text and images of those forms are missing from `bytes`. + /// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`]: export stops + /// reading there to stay bounded on hostile files, so the text and images of those forms, or + /// those pictures, are missing from `bytes`. pub truncated_pages: Vec, } +/// An image object prepared for Office export: its file extension and encoded bytes, or why it is +/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`]; `Err(false)`: it can't be encoded). +type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>; + impl crate::Document { /// The pages as paragraphs and images (for Word, HTML and RTF export), including what /// form XObjects draw. pub fn export_pages(&self) -> Vec { - self.export_pages_checked().0 + self.export_pages_checked(true).0 } - /// [`Self::export_pages`], and the pages whose forms were left partly unread - /// ([`OfficeExport::truncated_pages`]). - fn export_pages_checked(&self) -> (Vec, Vec) { + /// [`Self::export_pages`], and the pages whose forms were left partly unread or whose + /// pictures went past [`MAX_EXPORT_IMAGE_BYTES`] ([`OfficeExport::truncated_pages`]). Without + /// `images` no picture is prepared. + fn export_pages_checked(&self, images: bool) -> (Vec, Vec) { let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) }; let mut truncated = Vec::new(); + // Each image object is encoded once and shared by every place that draws it. + let mut encoded: HashMap = HashMap::new(); + let mut image_bytes = MAX_EXPORT_IMAGE_BYTES; let pages = self .info .pages @@ -217,13 +233,21 @@ impl crate::Document { } }) .collect(); - let images = pdfcraft_edit::reading_images(cos, i) - .unwrap_or_default() + let placed = if images { pdfcraft_edit::reading_images(cos, i).unwrap_or_default() } else { Vec::new() }; + let mut over_budget = false; + let images = placed .iter() .filter_map(|im| { - let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?; + let object = im.object?; + let prepared = encoded.entry(object).or_insert_with(|| { + let (ext, bytes) = pdfcraft_create::image_file(cos, object).map_err(|_| false)?; + image_bytes = image_bytes.checked_sub(bytes.len()).ok_or(true)?; + Ok((if ext == "jpg" { "jpg" } else { "png" }, Arc::from(bytes))) + }); + over_budget |= matches!(prepared, Err(true)); + let (ext, bytes) = prepared.clone().ok()?; Some(pdfcraft_export::Image { - ext: if ext == "jpg" { "jpg" } else { "png" }, + ext, bytes, rect: visible_rect(im.rect), size: [im.matrix[0].hypot(im.matrix[1]) * unit, im.matrix[2].hypot(im.matrix[3]) * unit], @@ -232,6 +256,9 @@ impl crate::Document { }) .collect(); // The page colour (Edit a PDF ▸ Background, a producer's page fill) for formats that keep one. + if over_budget { + truncated.push(i); + } 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 } }) @@ -247,7 +274,8 @@ impl crate::Document { /// [`Self::export_office`], and the pages whose text it had to leave out /// ([`OfficeExport::truncated_pages`]). pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport { - let (pages, truncated_pages) = self.export_pages_checked(); + // RTF leaves pictures out. + let (pages, truncated_pages) = self.export_pages_checked(format != OfficeFormat::Rtf); let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string()); let bytes = match format { OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3d347e6492..47ad482d46 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6645,7 +6645,7 @@ fn exporting_a_jpeg_logo_to_word_keeps_the_image() { assert!(jpeg.starts_with(&[0xFF, 0xD8])); let docx = d.export_office(compare::OfficeFormat::Docx); assert!(docx.windows(21).any(|w| w == b"word/media/image1.jpg"), "overlap {overlaps}"); - assert!(docx.windows(jpeg.len()).any(|w| w == jpeg.as_slice()), "the JPEG is stored unchanged (overlap {overlaps})"); + assert!(docx.windows(jpeg.len()).any(|w| w == &jpeg[..]), "the JPEG is stored unchanged (overlap {overlaps})"); } } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 15e1a8548b..8d9026600d 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -8,5 +8,6 @@ mod document_fonts; mod form_rotation; mod marked_content_isolation; mod ocr_edit; +mod office_export_images; mod private_image_resize; mod private_text_glyphs; diff --git a/crates/engine/tests/it/office_export_images.rs b/crates/engine/tests/it/office_export_images.rs new file mode 100644 index 0000000000..4f2ff618fb --- /dev/null +++ b/crates/engine/tests/it/office_export_images.rs @@ -0,0 +1,43 @@ +//! Office export of pages that draw one image many times. + +use std::sync::Arc; + +use pdfcraft_engine::Session; + +fn pdf(objects: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.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", objects.len() + 1).as_bytes()); + out +} + +/// One image drawn many times is prepared once: every placement shares its encoded bytes. +#[test] +fn an_image_drawn_many_times_is_encoded_once() { + let placements = 500; + let content = "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(placements); + let bytes = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{40}\u{80}\u{ff}\nendstream".into(), + ]); + let mut s = Session::new(); + let id = s.open("many.pdf", None, Arc::new(bytes), None).unwrap(); + let pages = s.get(id).unwrap().export_pages(); + let images = &pages[0].images; + assert_eq!(images.len(), placements); + let first = images[0].bytes.as_ptr(); + assert!(!images[0].bytes.is_empty()); + assert!(images.iter().all(|im| im.bytes.as_ptr() == first), "each placement holds its own copy of the image"); +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index f3030ad58d..b4ea9e9652 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -35,7 +35,8 @@ pub struct Block { pub struct Image { /// "png" or "jpg". pub ext: &'static str, - pub bytes: Vec, + /// Shared: a picture drawn in many places is held once. + pub bytes: std::sync::Arc<[u8]>, pub rect: [f64; 4], /// Width and height before rotation, in points. pub size: [f64; 2], @@ -932,7 +933,7 @@ mod tests { ], images: vec![Image { ext: "png", - bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(), + bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), rect: [72.0, 450.0, 272.0, 600.0], size: [200.0, 150.0], rotation: 0.0, @@ -1374,7 +1375,7 @@ mod tests { /// file in the package, with a content type; JPEG pictures too, inline or placed. #[test] fn word_pictures_resolve_to_media_in_the_package() { - let jpeg = Image { ext: "jpg", bytes: vec![0xFF, 0xD8, 0xFF, 0xD9], ..page().images[0].clone() }; + let jpeg = Image { ext: "jpg", bytes: [0xFF, 0xD8, 0xFF, 0xD9].as_slice().into(), ..page().images[0].clone() }; for placed in [false, true] { let mut p = page(); p.images = vec![jpeg.clone()]; @@ -1401,7 +1402,7 @@ mod tests { fn word_export_bounds_the_number_of_pictures_and_skips_empty_ones() { let mut p = page(); let one = p.images[0].clone(); - p.images = vec![Image { bytes: Vec::new(), ..one.clone() }]; + p.images = vec![Image { bytes: [].as_slice().into(), ..one.clone() }]; p.images.extend(std::iter::repeat_n(one, MAX_MEDIA + 10)); let zip = docx(&[p], "T"); let rels = part(&zip, "word/_rels/document.xml.rels"); diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index 1a7250d9e0..886a4bdbde 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -391,7 +391,7 @@ mod tests { ], images: vec![Image { ext: "png", - bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(), + bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), rect: [72.0, 150.0, 300.0, 300.0], size: [228.0, 150.0], rotation: 0.0, @@ -619,7 +619,13 @@ mod tests { blocks.push(huge("inf", f64::INFINITY, 200.0 - k as f64)); } 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 }) + .map(|k| Image { + ext: "png", + bytes: [0; 4].as_slice().into(), + 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, background: None }; let bytes = pptx(&[p], "Hostile"); From f2f86455ddb0aa538bcb9c159b87effe0342aa08 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:09:45 +0000 Subject: [PATCH 170/517] fix(engine): read long underscore runs in one pass Word assembly checked whether the whole word so far was underscores for every glyph it added, so a long run of underscores on one line took quadratic time. The underscore state of the current word is kept as it grows. --- crates/engine/src/js.rs | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index a2c444bd62..c80537fbad 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -289,6 +289,12 @@ pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_rende .collect() } +#[cfg(test)] +thread_local! { + /// Characters read to tell underscore runs from other words. + static UNDERSCORE_CHARS_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A page's words in reading order, their boxes mapped from view space by `map` (and ordered /// again, so that [x0, y0, x1, y1] holds whatever way `map` turns them). pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32) -> [f32; 2]) -> Vec<(String, [f32; 4])> { @@ -296,6 +302,8 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32 let mut last_line = u32::MAX; // A space glyph ends the word before it. let mut gap = false; + // Whether the last word is an underscore run (a form's blank), which other glyphs don't join. + let mut last_under = false; for (i, g) in text.glyphs.iter().enumerate() { let line = text.line_of.get(i).copied().unwrap_or(0); if g.text.trim().is_empty() { @@ -303,10 +311,9 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32 continue; } let under = g.text.chars().all(|c| c == '_'); - let breaks = match words.last() { - None => true, - Some((w, _)) => gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || w.chars().all(|c| c == '_') != under, - }; + #[cfg(test)] + UNDERSCORE_CHARS_READ.with(|n| n.set(n.get() + g.text.len())); + let breaks = words.is_empty() || gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || last_under != under; gap = false; last_line = line; match words.last_mut() { @@ -317,7 +324,10 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32 r[2] = r[2].max(g.rect[2]); r[3] = r[3].max(g.rect[3]); } - _ => words.push((g.text.clone(), g.rect)), + _ => { + words.push((g.text.clone(), g.rect)); + last_under = under; + } } } words @@ -409,6 +419,27 @@ impl Session { mod review_tests { use super::*; + /// A long run of underscores (a form's blank) is read in one pass: each glyph is compared with + /// the word so far without reading the word again. + #[test] + fn a_long_underscore_run_is_read_in_one_pass() { + let n = 10_000; + let glyph = |i: usize, t: &str| pdfcraft_render::TextGlyph { + text: t.into(), + rect: [i as f32, 0.0, i as f32 + 1.0, 10.0], + quad: [[0.0; 2]; 4], + direction: [1.0, 0.0], + }; + let mut glyphs: Vec<_> = (0..n).map(|i| glyph(i, "_")).collect(); + glyphs.push(glyph(n, "x")); + let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs }; + UNDERSCORE_CHARS_READ.with(|c| c.set(0)); + let words = words_with(&text, |x, y| [x, y]); + assert_eq!(words.iter().map(|(w, _)| w.len()).collect::>(), [n, 1]); + let read = UNDERSCORE_CHARS_READ.with(std::cell::Cell::get); + assert!(read <= 4 * n, "{read} characters read for {n} glyphs"); + } + #[test] fn duplicate_field_names_diff_against_first_original() { let first = pdfcraft_js::FieldState::new("duplicate", pdfcraft_js::FieldType::Text, vec!["first".into()]); From 2d5c5054e22b77e1bce18bd43cf8398dfc93f2f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:11:06 +0000 Subject: [PATCH 171/517] fix(forms): merge a calculation's read names with a set calculation_order merged the names a Calculate script reads into the field's other reads by searching the list so far for each name, so a script naming many fields took quadratic time before the link limits applied. The merge looks names up in a set. --- crates/forms/src/calc_order.rs | 11 ++++++++++- crates/forms/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/forms/src/calc_order.rs b/crates/forms/src/calc_order.rs index e75a05d475..428c1724d0 100644 --- a/crates/forms/src/calc_order.rs +++ b/crates/forms/src/calc_order.rs @@ -122,6 +122,12 @@ pub(crate) fn run_order(doc: &Document, fields: &[Field]) -> (Vec<(usize, bool)> (run, extra) } +#[cfg(test)] +thread_local! { + /// Read names compared while merging a field's reads. + pub(crate) static NAMES_COMPARED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// The form's calculation order (`fields` as [`fields`] reads them), with each calculated field's /// reads and issues, and Fix order's order. pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { @@ -132,8 +138,11 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { .map(|(f, _)| { let mut names = f.actions.calculate.reads(); if let Some(js) = &f.actions.scripts.calculate { + let mut known: HashSet = names.iter().cloned().collect(); for name in crate::af::calculation_reads(js) { - if !names.contains(&name) { + #[cfg(test)] + NAMES_COMPARED.with(|n| n.set(n.get() + 1)); + if known.insert(name.clone()) { names.push(name); } } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b2e4994ecf..2b20428650 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2981,3 +2981,20 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() { assert_eq!(walk.next(), None); assert_eq!(page_refs(&doc), [page]); } + +/// A Calculate script naming thousands of fields is merged with the field's other reads in one +/// pass, not by searching the names so far for each one. +#[test] +fn a_script_reading_many_fields_merges_its_reads_in_one_pass() { + use crate::calc_order::NAMES_COMPARED; + let mut doc = one_page(); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("total")).unwrap(); + let n = 2_000; + let script: String = (0..n).map(|i| format!("getField(\"f{i}\").value + ")).collect::() + "0"; + set_field_script(&mut doc, "total", "calculate", Some(&format!("event.value = {script};"))).unwrap(); + NAMES_COMPARED.with(|c| c.set(0)); + let order = calculation_order(&doc, &fields(&doc)); + assert_eq!(order.fields[0].reads.len(), n); + let compared = NAMES_COMPARED.with(std::cell::Cell::get); + assert!(compared <= 4 * n, "{compared} names compared for {n} reads"); +} From b6cb15177484df065f7aa87231cf2e4f63785127 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:12:06 +0000 Subject: [PATCH 172/517] fix(measure): read a stream's XObject resources once when snapping Every Do operator resolved the stream's /XObject resources again, and resolving a direct dictionary copies it, so a large direct XObject dictionary and many short Do operators multiplied the copying. The dictionary is read on the first Do and kept for the rest of the stream. --- crates/measure/src/snap.rs | 14 +++++++++++++- crates/measure/src/tests.rs | 11 +++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index 3cf6bbd17e..37ee2975f7 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -250,6 +250,8 @@ impl Walker<'_> { let mut current = None; let mut closures = Vec::new(); let mut path = Geometry::default(); + // Read on the first `Do` and kept: resolving a direct dictionary copies it. + let mut xobjects = None; let point = |m: Matrix, x: f64, y: f64| { let (x, y) = m.apply(x, y); [x, y] @@ -384,7 +386,11 @@ impl Walker<'_> { } "Do" => { if let Some(name) = op.name(0) { - let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)); + let xobjects = xobjects.get_or_insert_with(|| { + #[cfg(test)] + XOBJECT_DICT_READS.with(|n| n.set(n.get() + 1)); + resources.get(b"XObject").map(|o| self.doc.resolve(o)) + }); if let Some(o) = xobjects.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(name)) { let r = o.as_ref(); if r.is_some_and(|r| self.seen.contains(&r)) { @@ -420,6 +426,12 @@ impl Walker<'_> { Ok(()) } } +#[cfg(test)] +thread_local! { + /// Times a content stream's `/XObject` resources were read. + pub(crate) static XOBJECT_DICT_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + pub fn geometry(doc: &Document, page_index: usize) -> Result { let p = crate::page(doc, page_index)?; let resources = p.dict.get(b"Resources").map(|o| doc.resolve(o)); diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 303f9b4386..06bc1e42db 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -403,3 +403,14 @@ fn a_scale_ratio_converts_its_page_side_to_points() { assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}"); } } +/// A content stream reads its `/XObject` resources once, not again (as a copy of a direct +/// dictionary) for every `Do`. +#[test] +fn xobject_resources_are_read_once_per_stream() { + let d = fixture(&"/Missing Do\n".repeat(10_000), "", &[]); + snap::XOBJECT_DICT_READS.with(|c| c.set(0)); + let paths = snap::geometry(&d, 0).unwrap(); + assert!(paths.segments.is_empty()); + let reads = snap::XOBJECT_DICT_READS.with(std::cell::Cell::get); + assert!(reads <= 1, "{reads} reads of the page's XObject resources"); +} From 240d051b4e9973c5d980560657f6377c63f0856b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:12:43 +0000 Subject: [PATCH 173/517] fix(measure): read viewports within bounds when finding a scale scale_at converted every number of a viewport's /BBox before checking it had four, and read every viewport the page listed, so many viewports sharing one long box array took time proportional to their product. A box of any other length is skipped unread, and at most 1024 viewports are read, the most set_scale gives a page. --- crates/measure/src/lib.rs | 20 +++++++++++++++++--- crates/measure/src/tests.rs | 13 +++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index 72b6c4b07b..dda45a75ae 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -497,6 +497,15 @@ pub fn list(doc: &Document) -> Listing { } out } +/// Most viewports [`set_scale`] gives a page, and most [`scale_at`] reads. +const MAX_VIEWPORTS: usize = 1024; + +#[cfg(test)] +thread_local! { + /// Numbers read from viewport `/BBox` arrays. + pub(crate) static VIEWPORT_NUMBERS_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Last containing viewport wins, using the first point of the measurement (ISO 32000-2 §12.9). /// Outside every viewport the page measures true size in inches. pub fn scale_at(doc: &Document, page_index: usize, at: Point) -> Result { @@ -510,12 +519,17 @@ pub fn scale_at_or(doc: &Document, page_index: usize, at: Point, per_point: f64, if let Some(o) = p.dict.get(b"VP") { let o = doc.resolve(o); let arr = o.as_array().ok_or_else(|| invalid("invalid page viewports"))?; - for viewport in arr.iter().rev() { + // The last viewports win; a page holds at most MAX_VIEWPORTS of its own. + for viewport in arr.iter().rev().take(MAX_VIEWPORTS) { let o = doc.resolve(viewport); let Some(d) = o.as_dict() else { continue }; if let Some(bbox) = d.get(b"BBox") { let o = doc.resolve(bbox); - let b: Option> = o.as_array().and_then(|a| a.iter().map(|v| doc.resolve(v).as_f64()).collect()); + let b: Option> = o.as_array().filter(|a| a.len() == 4).and_then(|a| { + #[cfg(test)] + VIEWPORT_NUMBERS_READ.with(|n| n.set(n.get() + a.len())); + a.iter().map(|v| doc.resolve(v).as_f64()).collect() + }); if let Some(b) = b.filter(|b| b.len() == 4 && b.iter().all(|v| v.is_finite())) && at[0] >= b[0].min(b[2]) && at[0] <= b[0].max(b[2]) @@ -541,7 +555,7 @@ pub fn set_scale(doc: &mut Document, page_index: usize, bbox: [f64; 4], name: &s Some(o) => doc.resolve(o).as_array().cloned().ok_or_else(|| invalid("invalid page viewports"))?, None => Vec::new(), }; - if list.len() >= 1024 { + if list.len() >= MAX_VIEWPORTS { return Err(invalid("page has too many measurement viewports")); } let mut d = Dict::new(); diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 06bc1e42db..259caeb5f3 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -414,3 +414,16 @@ fn xobject_resources_are_read_once_per_stream() { let reads = snap::XOBJECT_DICT_READS.with(std::cell::Cell::get); assert!(reads <= 1, "{reads} reads of the page's XObject resources"); } +/// Finding the scale under a point reads only well-formed viewport boxes, and only as many +/// viewports as a page can be given, however many a file lists or however long their boxes. +#[test] +fn scale_lookup_reads_viewports_within_bounds() { + let vp = vec!["5 0 R"; 5_000].join(" "); + let long_box = vec!["0"; 2_000].join(" "); + let d = fixture("", &format!("/VP [{vp}]"), &["<< /Type /Viewport /BBox 6 0 R >>", &format!("[{long_box}]")]); + VIEWPORT_NUMBERS_READ.with(|c| c.set(0)); + let scale = scale_at(&d, 0, [10.0, 10.0]).unwrap(); + assert_eq!(scale.unit, "in"); + let read = VIEWPORT_NUMBERS_READ.with(std::cell::Cell::get); + assert!(read <= 4 * 1024, "{read} viewport numbers read"); +} From 05d09de1e337c3dc40bac6286721fe85239e6c8b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:13:45 +0000 Subject: [PATCH 174/517] fix(annot): read a shared page tree Kids array once The page walk pushed a node's /Kids array for every new node, so many page tree nodes sharing one indirect /Kids array re-read it for each node: quadratic work while the seen check skipped every entry. A shared array object is read under the first node that lists it, and the walk stops after four million /Kids entries. --- crates/annot/src/lib.rs | 21 ++++++++++++++++----- crates/annot/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 6edfbf9cb7..61dd5ff1cc 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -665,17 +665,20 @@ pub fn page_refs(doc: &Document) -> Result, AnnotError> { /// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, /// never one entry per child, so a child listed many times or shared between nodes is skipped by /// the seen check without being queued first (`pdfcraft_model::pages` walks the same way). -/// Cycles end at the seen check and at most a million nodes are visited. +/// Cycles end at the seen check, a `/Kids` array object shared by several nodes is read once, +/// and at most a million nodes and four million `/Kids` entries are read. struct PageWalk<'a> { doc: &'a Document, seen: std::collections::HashSet, pending: Vec<(std::sync::Arc, usize)>, + /// `/Kids` entries read so far. + examined: usize, } impl<'a> PageWalk<'a> { fn new(doc: &'a Document, top: ObjRef) -> Self { let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)])); - Self { doc, seen: Default::default(), pending: vec![(top, 0)] } + Self { doc, seen: Default::default(), pending: vec![(top, 0)], examined: 0 } } } @@ -690,8 +693,9 @@ impl Iterator for PageWalk<'_> { }; let node = kid.as_ref(); *next += 1; + self.examined += 1; let Some(node) = node else { continue }; - if self.seen.len() >= 1_000_000 { + if self.seen.len() >= 1_000_000 || self.examined > 4_000_000 { self.pending.clear(); return None; } @@ -700,8 +704,15 @@ impl Iterator for PageWalk<'_> { } let obj = self.doc.get(node); let Some(d) = obj.as_dict() else { continue }; - match d.get(b"Kids").map(|k| self.doc.resolve(k)) { - Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)), + match d.get(b"Kids") { + // A `/Kids` array object shared by several nodes is read under the first: under + // the others it would only list nodes already seen. + Some(Object::Ref(array)) if d.name(b"Type") != Some(b"Page") => { + if self.seen.insert(*array) { + self.pending.push((self.doc.get(*array), 0)); + } + } + Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((self.doc.resolve(kids), 0)), _ => return Some(node), } } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a659966812..44328bbd06 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1504,3 +1504,25 @@ fn typed_signature_uses_nonzero_winding_fill() { assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}"); assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); } + +/// Many page tree nodes sharing one `/Kids` array read it once, not once per node. +#[test] +fn page_walk_reads_a_shared_kids_array_once() { + let mut doc = fixture(); + let root = ObjRef { num: 2, generation: 0 }; + let (first, second) = (ObjRef { num: 3, generation: 0 }, ObjRef { num: 4, generation: 0 }); + let shared = doc.add(Object::Array([Object::Ref(first), Object::Ref(second)].into_iter().cycle().take(2_000).collect())); + let nodes: Vec = (0..1_000) + .map(|_| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Pages")); + d.set(b"Kids".to_vec(), Object::Ref(shared)); + Object::Ref(doc.add(d)) + }) + .collect(); + doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(nodes))).unwrap(); + let mut walk = PageWalk::new(&doc, root); + let pages: Vec = walk.by_ref().collect(); + assert_eq!(pages, [first, second]); + assert!(walk.examined <= 10_000, "{} kids entries read", walk.examined); +} From c2e96de0b240f6d41ef340cc50c29d9e74198232 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:14:34 +0000 Subject: [PATCH 175/517] fix(annot): extend each page's annotation list once when adding many links add_many added links one at a time, and each addition walked the page tree and copied and rewrote the page's whole /Annots array, so a batch of links cost quadratic time. The pages are found once and each page's new links are appended in one update. --- crates/annot/src/lib.rs | 11 ++++++++++- crates/annot/src/links.rs | 37 ++++++++++++++++++++++++++++++------- crates/annot/src/tests.rs | 15 +++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 61dd5ff1cc..d1d74ce410 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -728,7 +728,16 @@ fn page_ref(doc: &Document, page: usize) -> Result { fn annots(doc: &Document, page: ObjRef) -> Vec { let obj = doc.get(page); let Some(a) = obj.as_dict().and_then(|d| d.get(b"Annots")) else { return Vec::new() }; - doc.resolve(a).as_array().cloned().unwrap_or_default() + let list = doc.resolve(a).as_array().cloned().unwrap_or_default(); + #[cfg(test)] + ANNOTS_ENTRIES_COPIED.with(|n| n.set(n.get() + list.len())); + list +} + +#[cfg(test)] +thread_local! { + /// Entries copied out of pages' `/Annots` arrays. + static ANNOTS_ENTRIES_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Replace a page's `/Annots`, writing into the shared array object when it is indirect. diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 4d3eef632b..f11eecbd83 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -232,6 +232,16 @@ fn checked_rect(rect: [f64; 4]) -> Result<[f64; 4], AnnotError> { /// Add a link over `rect` (user space). Returns its index in the page's `/Annots`. pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result { let p = page_ref(doc, page)?; + let obj = new_link(doc, p, rect, action, style)?; + let mut list = annots(doc, p); + list.push(Object::Ref(obj)); + let index = list.len() - 1; + set_annots(doc, p, list)?; + Ok(index) +} + +/// A link annotation object for page `p`, not yet on the page's `/Annots`. +fn new_link(doc: &mut Document, p: ObjRef, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result { let r = checked_rect(rect)?; if matches!(action, LinkAction::Other(_)) { return Err(AnnotError::Invalid("choose a page or a web address".into())); @@ -244,12 +254,7 @@ pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, d.set(b"F".to_vec(), Object::Int(4)); write_style(&mut d, style); write_action(doc, &mut d, action)?; - let obj = doc.add(Object::Dict(d)); - let mut list = annots(doc, p); - list.push(Object::Ref(obj)); - let index = list.len() - 1; - set_annots(doc, p, list)?; - Ok(index) + Ok(doc.add(Object::Dict(d))) } fn link_at(doc: &Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> { @@ -414,12 +419,30 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range, String)> { /// Create links for found URLs: `(page, quads, uri)` with quads in user space (one link per /// line rectangle). Returns how many links were added. pub fn add_many(doc: &mut Document, links: &[(usize, Vec<[f64; 4]>, String)], style: &LinkStyle) -> Result { + let pages = page_refs(doc)?; + // Each page's `/Annots` is extended once, with its new links in the order given. + let mut added: Vec<(ObjRef, Vec)> = Vec::new(); + let mut slot: std::collections::HashMap = std::collections::HashMap::new(); let mut n = 0; for (page, rects, uri) in links { + let p = pages.get(*page).copied().ok_or(AnnotError::NoSuchPage(*page))?; + let action = LinkAction::Uri(uri.clone()); for r in rects { - add(doc, *page, *r, &LinkAction::Uri(uri.clone()), style)?; + let obj = new_link(doc, p, *r, &action, style)?; + let k = *slot.entry(p).or_insert_with(|| { + added.push((p, Vec::new())); + added.len() - 1 + }); + if let Some((_, list)) = added.get_mut(k) { + list.push(Object::Ref(obj)); + } n += 1; } } + for (p, new) in added { + let mut list = annots(doc, p); + list.extend(new); + set_annots(doc, p, list)?; + } Ok(n) } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 44328bbd06..cfd80093e1 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1526,3 +1526,18 @@ fn page_walk_reads_a_shared_kids_array_once() { assert_eq!(pages, [first, second]); assert!(walk.examined <= 10_000, "{} kids entries read", walk.examined); } + +/// Adding many links to a page extends its `/Annots` once, not once per link. +#[test] +fn many_links_extend_a_page_annotation_list_once() { + let mut doc = fixture(); + let before = annots(&doc, page_refs(&doc).unwrap()[0]).len(); + let n = 2_000; + let rects: Vec<[f64; 4]> = (0..n).map(|i| [10.0, i as f64 * 0.1, 50.0, i as f64 * 0.1 + 5.0]).collect(); + ANNOTS_ENTRIES_COPIED.with(|c| c.set(0)); + let added = links::add_many(&mut doc, &[(0, rects, "https://example.com".into())], &links::LinkStyle::default()).unwrap(); + let copied = ANNOTS_ENTRIES_COPIED.with(std::cell::Cell::get); + assert_eq!(added, n); + assert_eq!(annots(&doc, page_refs(&doc).unwrap()[0]).len(), before + n); + assert!(copied <= 4 * n, "{copied} annotation entries copied for {n} links"); +} From 8adf8e2d3b4a86aaa688f3347e07560f85d11530 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:58:47 +0000 Subject: [PATCH 176/517] fix(xfdf): read an indirect /F when deciding a comment is locked against import --- crates/xfdf/src/lib.rs | 7 ++++--- crates/xfdf/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 99470b58a1..5c0be52326 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1037,8 +1037,9 @@ const LOCKED: i64 = 128; /// An existing annotation an import must never replace, move or unlock: one the user locked, a /// redaction mark (replacing it would drop the region the user marked to remove), or a form /// widget or field (replacing it would set the field's value and policy around the form setter). -fn import_protected(x: &Dict) -> bool { - x.int(b"F").unwrap_or(0) & LOCKED != 0 || matches!(x.name(b"Subtype"), Some(b"Redact" | b"Widget")) || x.contains(b"FT") +fn import_protected(doc: &Document, x: &Dict) -> bool { + let flags = x.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + flags & LOCKED != 0 || matches!(x.name(b"Subtype"), Some(b"Redact" | b"Widget")) || x.contains(b"FT") } fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { @@ -1089,7 +1090,7 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result"#; + assert_eq!(import(&mut doc, xfdf).unwrap().comments, 1); + let annots = doc.get(page).as_dict().and_then(|p| p.get(b"Annots").and_then(Object::as_array).cloned()).unwrap(); + assert!(annots.contains(&Object::Ref(note)), "the locked comment was replaced"); + assert_eq!(doc.get(note), before, "the locked comment was changed"); +} From 110b7c678a1658c8b880be1d5f6d255c8f2e2248 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:29:44 +0000 Subject: [PATCH 177/517] fix(a11y): read the enclosing marked content in one step per operator Marked-text extraction and figure bounds searched the whole marked-content stack before every operator, so deeply nested unmarked BMC blocks made a page quadratic. The text stack now holds each level's effective id (its own, else the enclosing one's), and the bounds pass counts the open sequences that match. --- crates/a11y/src/alt.rs | 17 ++++++++++++---- crates/a11y/src/headings.rs | 21 +++++++++++-------- crates/a11y/src/lib.rs | 14 +++++++++++++ crates/a11y/src/tests.rs | 40 +++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index e4755ab385..ed91f9e50f 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -183,16 +183,19 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { } /// Where the marked content `ids` is drawn on a page (images, forms and paths). -fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { +pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let props = page_properties(doc, page); let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]; + // Whether each open marked-content sequence is one of `ids`, and how many of them are. let mut marks: Vec = Vec::new(); + let mut matching = 0usize; let mut bbox = None; let mut path: Vec<(f64, f64)> = Vec::new(); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { - let inside = marks.iter().any(|m| *m); + crate::count_mark_reads(1); + let inside = matching > 0; let top = ctm.last().copied().unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); match op.op.as_slice() { b"q" => ctm.push(top), @@ -207,9 +210,15 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; } } b"BMC" => marks.push(false), - b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))), + b"BDC" => { + let wanted = bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n)); + matching += usize::from(wanted); + marks.push(wanted); + } b"EMC" => { - marks.pop(); + if marks.pop() == Some(true) { + matching = matching.saturating_sub(1); + } } b"m" | b"l" => { if let Some([x, y]) = op.nums::<2>() { diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 537ed908d7..d3a478cb95 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -182,6 +182,7 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let res = sub(doc, page, b"Resources"); let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties")); let mut metrics: HashMap, Metrics> = HashMap::new(); + // The id each open marked-content sequence is in: its own, else the enclosing one's. let mut stack: Vec> = Vec::new(); let mut out: HashMap = HashMap::new(); // Where each id's last glyph ended, in user space. @@ -191,16 +192,20 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let mut unsaved = 0usize; let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { - let id = stack.iter().rev().find_map(|m| *m); + crate::count_mark_reads(1); + let id = stack.last().copied().flatten(); match op.op.as_slice() { - b"BMC" => stack.push(None), - b"BDC" => stack.push(match op.operands.get(1) { - Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int), - Some(Object::Name(n)) => { - props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))) + b"BMC" => stack.push(id), + b"BDC" => stack.push( + match op.operands.get(1) { + Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int), + Some(Object::Name(n)) => { + props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))) + } + _ => None, } - _ => None, - }), + .or(id), + ), b"EMC" => { stack.pop(); } diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index ff81cc4c97..22fac756ed 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -21,6 +21,20 @@ mod report; mod structure; mod tags; +#[cfg(test)] +thread_local! { + /// Marked-content stack entries read to decide which marks enclose an operator, for the tests + /// that keep that constant per operator. + static MARK_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` marked-content stack reads (tests only). +#[inline] +fn count_mark_reads(_n: usize) { + #[cfg(test)] + MARK_READS.with(|c| c.set(c.get().saturating_add(_n))); +} + pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt}; pub use autotag::{AutoTagError, AutoTagResult, autotag_text}; pub use autotag_fields::tag_form_fields; diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 15ca643e83..b5eb547420 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1023,6 +1023,46 @@ fn glyph_by_tm(text: &str, size: f64, y: f64) -> String { ops + " ET" } +/// Marked-content stack reads while `f` runs. +fn mark_reads(f: impl FnOnce()) -> usize { + MARK_READS.with(|c| c.set(0)); + f(); + MARK_READS.with(|c| c.get()) +} + +/// Page content nesting `depth` unmarked `BMC` blocks around a marked one, with `ops` operators inside. +fn deeply_nested_page(doc: &mut Document, depth: usize, ops: usize) -> pdfcraft_cos::Dict { + use pdfcraft_cos::{Dict, Stream}; + let mut data = "/Figure << /MCID 0 >> BDC ".repeat(1); + data.push_str(&"/X BMC ".repeat(depth)); + data.push_str(&"0 0 1 1 re f ".repeat(ops)); + data.push_str(&"EMC ".repeat(depth + 1)); + let content = doc.add(Stream::flate(Dict::new(), data.as_bytes())); + [(b"Contents".to_vec(), Object::Ref(content))].into_iter().collect() +} + +#[test] +fn marked_text_reads_its_enclosing_id_in_constant_steps() { + let mut doc = Document::new_empty(); + let page = deeply_nested_page(&mut doc, 5_000, 5_000); + let reads = mark_reads(|| { + crate::headings::mcid_texts(&doc, &page); + }); + // About 15 000 operators; a scan of the stack per operator reads millions of entries. + assert!(reads <= 4 * 15_000, "{reads} stack reads"); +} + +#[test] +fn figure_bounds_read_the_marked_content_state_in_constant_steps() { + let mut doc = Document::new_empty(); + let page = deeply_nested_page(&mut doc, 5_000, 5_000); + // No mark encloses figure 7, so a scan of the stack per operator reads all of it. + let reads = mark_reads(|| { + crate::alt::content_bbox(&doc, &page, &std::collections::HashSet::from([7])); + }); + assert!(reads <= 4 * 15_000, "{reads} stack reads"); +} + /// One tagged page whose H1 headings show `contents` (one marked-content sequence each). fn heading_page(contents: &[String]) -> Document { let marked: Vec = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect(); From 38211588c754da1a7319dc5fa0f2b0bf54069815 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:32:02 +0000 Subject: [PATCH 178/517] fix(a11y): find every figure's bounds on a page in one pass Figure listing decoded and parsed a page's content once per figure on it, so many small Figure elements pointing at one page multiplied the work. The figures are grouped by page and one pass gathers the bounds of every marked-content id they own; what a sequence draws also counts for the sequences around it, as before. --- crates/a11y/src/alt.rs | 86 ++++++++++++++++++++++++++++++---------- crates/a11y/src/lib.rs | 2 + crates/a11y/src/tests.rs | 23 ++++++++++- 3 files changed, 89 insertions(+), 22 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index ed91f9e50f..fe8eb292c7 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -182,16 +182,47 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { } } -/// Where the marked content `ids` is drawn on a page (images, forms and paths). -pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { +/// An open marked-content sequence while bounds are gathered: its id, when it is one of those asked +/// for, and the bounds of what has been drawn in it so far. +struct Mark { + id: Option, + drawn: Option<[f64; 4]>, +} + +/// Adds `b` to what the innermost open sequence has drawn. +fn draw(marks: &mut [Mark], b: [f64; 4]) { + if let Some(m) = marks.last_mut() { + m.drawn = Some(union(m.drawn, b)); + } +} + +/// Closes the innermost open sequence: what it drew counts for its id and for the sequence around it. +fn close(marks: &mut Vec, matching: &mut usize, out: &mut HashMap) { + let Some(m) = marks.pop() else { return }; + if m.id.is_some() { + *matching = matching.saturating_sub(1); + } + let Some(b) = m.drawn else { return }; + if let Some(id) = m.id { + let e = out.entry(id).or_insert(b); + *e = union(Some(*e), b); + } + draw(marks, b); +} + +/// Where each of the marked content `ids` is drawn on a page (images, forms and paths), in one pass +/// over the page. What a sequence draws counts for every sequence of `ids` around it too. +pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet) -> HashMap { let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let props = page_properties(doc, page); + #[cfg(test)] + crate::BOUNDS_PASSES.with(|c| c.set(c.get() + 1)); let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]; - // Whether each open marked-content sequence is one of `ids`, and how many of them are. - let mut marks: Vec = Vec::new(); + // The open marked-content sequences, and how many of them are one of `ids`. + let mut marks: Vec = Vec::new(); let mut matching = 0usize; - let mut bbox = None; + let mut out = HashMap::new(); let mut path: Vec<(f64, f64)> = Vec::new(); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { crate::count_mark_reads(1); @@ -209,17 +240,13 @@ pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> O *t = m.then(&top); } } - b"BMC" => marks.push(false), + b"BMC" => marks.push(Mark { id: None, drawn: None }), b"BDC" => { - let wanted = bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n)); - matching += usize::from(wanted); - marks.push(wanted); - } - b"EMC" => { - if marks.pop() == Some(true) { - matching = matching.saturating_sub(1); - } + let id = bdc_mcid(doc, &props, &op).filter(|n| ids.contains(n)); + matching += usize::from(id.is_some()); + marks.push(Mark { id, drawn: None }); } + b"EMC" => close(&mut marks, &mut matching, &mut out), b"m" | b"l" => { if let Some([x, y]) = op.nums::<2>() { path.push(top.apply(x, y)); @@ -240,7 +267,7 @@ pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> O b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"n" => { if inside && !matches!(op.op.as_slice(), b"n") { for (x, y) in &path { - bbox = Some(union(bbox, [*x, *y, *x, *y])); + draw(&mut marks, [*x, *y, *x, *y]); } } path.clear(); @@ -260,13 +287,16 @@ pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> O } _ => top.bbox([0.0, 0.0, 1.0, 1.0]), }; - bbox = Some(union(bbox, unit)); + draw(&mut marks, unit); } - b"BI" if inside => bbox = Some(union(bbox, top.bbox([0.0, 0.0, 1.0, 1.0]))), + b"BI" if inside => draw(&mut marks, top.bbox([0.0, 0.0, 1.0, 1.0])), _ => {} } } - bbox + while !marks.is_empty() { + close(&mut marks, &mut matching, &mut out); + } + out } /// The figures, in document order. @@ -275,14 +305,28 @@ pub fn figures(doc: &Document) -> Vec
{ let role_map = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let pages = pdfcraft_model::pages(doc); let index: HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); - elements(doc) + let figures: Vec<(Element, Option)> = elements(doc) .into_iter() .filter(|e| e.dict.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure")) .map(|e| { let page = e.page.and_then(|p| index.get(&p).copied()); + (e, page) + }) + .collect(); + let mut wanted: HashMap> = HashMap::new(); + for (e, page) in &figures { + if let Some(p) = page { + wanted.entry(*p).or_default().extend(mcids(&e.dict)); + } + } + let bounds: HashMap> = + wanted.into_iter().filter_map(|(p, ids)| Some((p, content_bboxes(doc, &pages.get(p)?.dict, &ids)))).collect(); + figures + .into_iter() + .map(|(e, page)| { let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())); - let ids: HashSet = mcids(&e.dict).into_iter().collect(); - let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids)); + let on_page = page.and_then(|p| bounds.get(&p)); + let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b)))); Figure { obj: e.object, page, alt, bbox } }) .collect() diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 22fac756ed..21d8ea5820 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -26,6 +26,8 @@ thread_local! { /// Marked-content stack entries read to decide which marks enclose an operator, for the tests /// that keep that constant per operator. static MARK_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Page content passes made to find figure bounds. + static BOUNDS_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Counts `n` marked-content stack reads (tests only). diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index b5eb547420..a24f59edb2 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1058,11 +1058,32 @@ fn figure_bounds_read_the_marked_content_state_in_constant_steps() { let page = deeply_nested_page(&mut doc, 5_000, 5_000); // No mark encloses figure 7, so a scan of the stack per operator reads all of it. let reads = mark_reads(|| { - crate::alt::content_bbox(&doc, &page, &std::collections::HashSet::from([7])); + crate::alt::content_bboxes(&doc, &page, &std::collections::HashSet::from([7])); }); assert!(reads <= 4 * 15_000, "{reads} stack reads"); } +#[test] +fn figure_bounds_take_one_pass_over_each_page() { + let n = 50; + let content: Vec = (0..n).map(|i| format!("/Figure << /MCID {i} >> BDC {i} 0 1 1 re f EMC")).collect(); + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 6 + i)).collect(); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(), + stream(&content.join(" ")), + format!("<< /Type /StructTreeRoot /K [{}] >>", kids.join(" ")), + ]; + objs.extend((0..n).map(|i| format!("<< /S /Figure /Pg 3 0 R /K {i} >>"))); + let doc = pdf(&objs, ""); + BOUNDS_PASSES.with(|c| c.set(0)); + let f = figures(&doc); + assert_eq!(BOUNDS_PASSES.with(|c| c.get()), 1, "page passes"); + assert_eq!(f.len(), n); + assert!(f.iter().enumerate().all(|(i, f)| f.bbox == Some([i as f64, 0.0, i as f64 + 1.0, 1.0]))); +} + /// One tagged page whose H1 headings show `contents` (one marked-content sequence each). fn heading_page(contents: &[String]) -> Document { let marked: Vec = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect(); From 1cc392e5b04ada01b130236c6d101636e8bb62da Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:33:28 +0000 Subject: [PATCH 179/517] fix(fonts): cut ToUnicode destinations to the CMap limit A sequential bfrange stores one destination and builds every covered glyph's text from all of it, so one huge destination and many repeats of its codes made text extraction copy megabytes per glyph. Destinations keep at most 256 UTF-16 units (the format's 512 bytes), and marked-text extraction stops asking for a glyph's text once its id has all the text it keeps. --- crates/a11y/src/headings.rs | 12 ++++++------ crates/fonts/src/pdf.rs | 22 ++++++++++++++++++++-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index d3a478cb95..41c749ed3c 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -285,17 +285,17 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { }; for (code, len) in m.codes(&s.bytes) { let trm = tm.then(&gs.ctm); + // A glyph's text is only built while its id still takes more. if let Some(id) = id + && out.get(&id).is_none_or(|t| t.len() < MAX_TITLE * 4) && let Some(glyph) = m.text_of(code) { let text = out.entry(id).or_default(); - if text.len() < MAX_TITLE * 4 { - let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale)); - if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') { - text.push(' '); - } - text.push_str(&glyph); + let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale)); + if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') { + text.push(' '); } + text.push_str(&glyph); } let advance = m.width(code) * gs.size + gs.char_spacing + if m.is_space(code, len) { gs.word_spacing } else { 0.0 }; tm = Matrix::translate(advance * gs.scale, 0.0).then(&tm); diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0aaed6939e..0badcfc3a9 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -905,9 +905,13 @@ impl Metrics { } } -/// The UTF-16 code units of CMap hex bytes. +/// Most UTF-16 units kept from one ToUnicode destination: the CMap format allows destinations of up +/// to 512 bytes, and every glyph a sequential range covers builds its text from the whole of one. +const MAX_DESTINATION_UNITS: usize = 256; + +/// The UTF-16 code units of CMap hex bytes, at most [`MAX_DESTINATION_UNITS`] of them. fn code_units(b: &[u8]) -> Vec { - b.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect() + b.chunks(2).take(MAX_DESTINATION_UNITS).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect() } /// UTF-16BE (with surrogates) from CMap hex bytes. @@ -1262,6 +1266,20 @@ mod tests { use super::*; + /// Every glyph of a sequential range builds its text from the destination, so a long one is cut + /// to the CMap limit once, when it is read. + #[test] + fn unicode_destinations_are_cut_to_the_cmap_limit() { + let long = "0041".repeat(4096); + let cmap = format!("2 beginbfrange <0010> <0020> <{long}> <0030> <0030> [<{long}>] endbfrange 1 beginbfchar <0040> <{long}> endbfchar"); + let mut u = Unicode::default(); + parse_to_unicode(cmap.as_bytes(), &mut u); + for code in [0x10, 0x20, 0x30, 0x40] { + let text = u.text(code).unwrap_or_default(); + assert!(!text.is_empty() && text.len() <= MAX_DESTINATION_UNITS * 3, "code {code:#x}: {} bytes", text.len()); + } + } + /// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one. #[test] fn low_code_tables_end_with_the_last_covered_code() { From 75c4275810edafaa3b607d136b18569d18c797cd Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:35:00 +0000 Subject: [PATCH 180/517] fix(fonts): look up CID and width ranges by binary search Code-to-CID and /W range lookups scanned every record in declaration order, once per glyph and once per explicit ToUnicode entry while a font was read, so a few thousand records multiplied by as many codes took billions of steps. Both tables are indexed once into stretches whose first-declared covering record wins, the precedence the scan had, and the /W ranges stop at the CMap entry limit. --- crates/fonts/src/pdf.rs | 157 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 141 insertions(+), 16 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0badcfc3a9..4a118a07d3 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -33,9 +33,9 @@ pub struct Metrics { widths: Vec, /// Composite fonts: CID → width, and CID ranges with one width. cid_widths: HashMap, - cid_ranges: Vec<(u32, u32, f64)>, + cid_ranges: Spans, /// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`). - cid_map: Vec<(u32, u32, u32)>, + cid_map: Spans, default: f64, /// Composite fonts with an explicit `/DW`: `/W` may leave out the glyphs that have that /// width (Chrome and Skia do), so a CID missing from `/W` is not a missing glyph. @@ -598,8 +598,8 @@ impl Metrics { first: 0, widths: Vec::new(), cid_widths: HashMap::new(), - cid_ranges: Vec::new(), - cid_map: Vec::new(), + cid_ranges: Spans::default(), + cid_map: Spans::default(), default: 500.0, explicit_default: false, std14: Some(crate::Std14::Helvetica), @@ -685,7 +685,7 @@ impl Metrics { if !spaces.is_empty() { m.codes = Codes::Ranges(spaces); } - m.cid_map = cids; + m.cid_map = Spans::new(cids); } let desc = font .get(b"DescendantFonts") @@ -698,6 +698,7 @@ impl Metrics { m.explicit_default = true; } if let Some(w) = desc.get(b"W").map(|w| doc.resolve(w)).and_then(|w| w.as_array().cloned()) { + let mut ranges = Vec::new(); let mut i = 0; while i < w.len() { let Some(c0) = doc.resolve(&w[i]).as_f64().map(|v| v as u32) else { break }; @@ -714,13 +715,16 @@ impl Metrics { } Some(c1) => { if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) { - m.cid_ranges.push((c0, c1 as u32, v)); + if ranges.len() < MAX_CMAP_ENTRIES { + ranges.push((c0, c1 as u32, v)); + } } i += 3; } None => break, } } + m.cid_ranges = Spans::new(ranges); } descriptor = dict(doc, desc.get(b"FontDescriptor")); } else { @@ -791,14 +795,14 @@ impl Metrics { if self.cid_map.is_empty() { return code; } - self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c.saturating_add(code - lo)) + self.cid_map.find(code).map_or(0, |(lo, _, c)| c.saturating_add(code.saturating_sub(*lo))) } /// The advance of `code` in text space per unit of font size. pub fn width(&self, code: u32) -> f64 { if self.composite { let cid = self.cid(code); - let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.iter().find(|(a, b, _)| cid >= *a && cid <= *b).map(|r| r.2)); + let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.find(cid).map(|r| r.2)); return w.unwrap_or(self.default) * self.scale; } if let Some(w) = code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) { @@ -858,7 +862,7 @@ impl Metrics { // A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for // those, only the subset's program says whether the glyph is there. return self.cid_widths.contains_key(&cid) - || self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b) + || self.cid_ranges.find(cid).is_some() || !self.subset || self.outlined_cids.get(cid as usize / 64).is_some_and(|w| (*w >> (cid % 64)) & 1 == 1) // No readable TrueType program to ask (e.g. CFF or missing): an explicit /DW @@ -958,16 +962,69 @@ fn add_range(out: &mut Unicode, budget: &mut usize, seq: &mut u32, lo: u32, hi: true } +#[cfg(test)] +thread_local! { + /// Code span records read by CID and width lookups, for the tests that keep a lookup logarithmic. + static SPAN_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts one span record read (tests only). +#[inline] +fn count_span_reads() { + #[cfg(test)] + SPAN_READS.with(|c| c.set(c.get().saturating_add(1))); +} + +/// Code spans `(lo, hi, value)` in declaration order, where the first-declared span covering a code +/// wins, indexed so that a lookup is one binary search. +#[derive(Clone, Debug, Default, PartialEq)] +struct Spans { + spans: Vec<(u32, u32, T)>, + /// The span covering each stretch of codes; see [`stretches`]. + cover: Vec<(u64, Option)>, +} + +impl Spans { + fn new(spans: Vec<(u32, u32, T)>) -> Self { + let bounds: Vec<(u32, u32)> = spans.iter().map(|s| (s.0, s.1)).collect(); + Spans { cover: stretches(&bounds, false), spans } + } + + fn is_empty(&self) -> bool { + self.spans.is_empty() + } + + /// The first-declared span covering `code`. + fn find(&self, code: u32) -> Option<&(u32, u32, T)> { + let step = self.cover.partition_point(|&(start, _)| { + count_span_reads(); + start <= u64::from(code) + }); + let &(_, record) = self.cover.get(step.checked_sub(1)?)?; + self.spans.get(record?) + } +} + /// For each stretch of codes, from its first code on, the latest-declared range covering it (`None` /// where no range does), sorted by code; a lookup is one binary search over it. fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option)> { + let spans: Vec<(u32, u32)> = ranges.iter().map(|r| (r.lo, r.hi)).collect(); + stretches(&spans, true) +} + +/// For each stretch of codes, from its first code on, the span `(lo, hi)` covering it (`None` where +/// none does), sorted by code: the latest-declared one when `later_wins`, else the first-declared. +/// At most `u32::MAX` spans are indexed. +fn stretches(spans: &[(u32, u32)], later_wins: bool) -> Vec<(u64, Option)> { + // Heap keys, largest for the winning record; the mapping is its own inverse. + let key = |record: u32| if later_wins { record } else { !record }; // Openings (first code, record) and closings (one past the last code), each in code order. The cover // can only change at one of their codes, so the sweep visits those codes in order. - let mut opens: Vec<(u32, u32)> = ranges.iter().enumerate().map(|(i, r)| (r.lo, i as u32)).collect(); + let mut opens: Vec<(u32, u32)> = spans.iter().zip(0..u32::MAX).map(|(&(lo, _), i)| (lo, i)).collect(); opens.sort_unstable(); - let mut closes: Vec = ranges.iter().map(|r| u64::from(r.hi) + 1).collect(); + let mut closes: Vec = spans.iter().take(opens.len()).map(|&(_, hi)| u64::from(hi) + 1).collect(); closes.sort_unstable(); - // Records that have opened and not yet closed; the top is the latest declared one. + // Keys of the records that have opened and not yet closed; the top is the winning one. let mut open: BinaryHeap = BinaryHeap::new(); let mut cover: Vec<(u64, Option)> = Vec::new(); let (mut next_open, mut next_close) = (0, 0); @@ -981,16 +1038,16 @@ fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option)> { while let Some(&(lo, record)) = opens.get(next_open) && u64::from(lo) <= p { - open.push(record); + open.push(key(record)); next_open += 1; } while closes.get(next_close).is_some_and(|&end| end <= p) { next_close += 1; } - while open.peek().is_some_and(|&top| ranges.get(top as usize).is_none_or(|r| u64::from(r.hi) < p)) { + while open.peek().is_some_and(|&top| spans.get(key(top) as usize).is_none_or(|&(_, hi)| u64::from(hi) < p)) { open.pop(); } - let winner = open.peek().map(|&top| top as usize); + let winner = open.peek().map(|&top| key(top) as usize); if cover.last().map(|&(_, w)| w) != Some(winner) { cover.push((p, winner)); } @@ -1266,6 +1323,74 @@ mod tests { use super::*; + /// Span records read while `f` runs. + fn span_reads(f: impl FnOnce()) -> usize { + SPAN_READS.with(|c| c.set(0)); + f(); + SPAN_READS.with(|c| c.get()) + } + + /// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`. + fn composite(doc: &mut Document, encoding: &str, w: Vec, to_unicode: &str) -> Dict { + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set(b"W".to_vec(), Object::Array(w)); + let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes())))); + let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes())))); + font( + doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", enc), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", tu), + ], + ) + } + + /// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all. + #[test] + fn widths_of_a_code_no_range_covers_take_few_steps() { + let mut doc = Document::new_empty(); + let w: Vec = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect(); + let f = composite(&mut doc, "", w, ""); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.width(4), 0.5); + let reads = span_reads(|| { + for _ in 0..1000 { + assert_eq!(m.width(3), 1.0); + } + }); + assert!(reads <= 1000 * 64, "{reads} span reads"); + } + + /// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it. + #[test] + fn overlapping_spans_resolve_to_the_first_declared() { + let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]); + let found = |code| spans.find(code).map(|s| s.2); + let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))]; + for (code, value) in want { + assert_eq!(found(code), value, "code {code}"); + } + } + + /// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map. + #[test] + fn many_unicode_entries_look_up_their_cids_in_few_steps() { + let n = 2000; + let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect(); + let encoding = format!("1 begincodespacerange <0000> endcodespacerange {n} begincidchar {cidchars} endcidchar"); + let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect(); + let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar"); + let mut doc = Document::new_empty(); + let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode); + let reads = span_reads(|| { + Metrics::from_dict(&doc, &f); + }); + assert!(reads <= n * 64, "{reads} span reads"); + } + /// Every glyph of a sequential range builds its text from the destination, so a long one is cut /// to the CMap limit once, when it is read. #[test] @@ -1736,7 +1861,7 @@ mod tests { let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); let mut m = Metrics::fallback(); - (_, m.cid_map) = parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange"); + m.cid_map = Spans::new(parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange").1); assert_eq!(m.cid(5), u32::MAX); let mut doc = Document::new_empty(); let mut enc = Dict::new(); From 5609bcbe37728d65186303b4c8817dbdb8e8c3b8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:36:06 +0000 Subject: [PATCH 181/517] fix(fonts): wrap text measuring each word once Line wrapping built and measured the whole candidate line for every word, so a long field value or annotation text at a tiny font size, with many words on one line, took quadratic time and copying. The wrapper keeps the line's width as it goes and measures each word, space and broken character once. --- crates/fonts/src/lib.rs | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index ec103ed5f0..37a2ea74d8 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -88,17 +88,28 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec { } /// [`wrap`], measuring with `measure` (for example [`cjk_width`]). +/// +/// A line measures as the sum of its words, the spaces between them and the characters of a broken +/// word, each measured once, so the work stays linear in `text` however much fits on a line. pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec { let mut lines = Vec::new(); + let space = measure(" ", size); + let mut buf = [0u8; 4]; // A CRLF pair is one break, a lone CR or LF is one break each. for para in text.replace("\r\n", "\n").split(['\n', '\r']) { let mut line = String::new(); + let mut line_width = 0.0; // No word is on the line yet; an empty word (a leading space) still starts it. let mut fresh = true; for word in para.split(' ') { - let candidate = if fresh { word.to_string() } else { format!("{line} {word}") }; - if measure(&candidate, size) <= width || fresh && measure(word, size) <= width { - line = candidate; + let word_width = measure(word, size); + let candidate = if fresh { word_width } else { line_width + space + word_width }; + if candidate <= width { + if !fresh { + line.push(' '); + } + line.push_str(word); + line_width = candidate; fresh = false; continue; } @@ -108,11 +119,15 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) } else { lines.push(std::mem::take(&mut line)); } + line_width = 0.0; for ch in word.chars() { - if !line.is_empty() && measure(&format!("{line}{ch}"), size) > width { + let ch_width = measure(ch.encode_utf8(&mut buf), size); + if !line.is_empty() && line_width + ch_width > width { lines.push(std::mem::take(&mut line)); + line_width = 0.0; } line.push(ch); + line_width += ch_width; } fresh = line.is_empty(); } @@ -442,4 +457,20 @@ mod tests { let lines = wrap_with("日本語のテキストは文字単位で折り返されます", 10.0, 45.0, cjk_width); assert!(lines.len() > 3 && lines.iter().all(|l| cjk_width(l, 10.0) <= 45.0), "{lines:?}"); } + + /// Wrapping measures each word and character a bounded number of times, however many fit on a line. + #[test] + fn wrapping_measures_each_word_a_few_times() { + let measured = std::cell::Cell::new(0usize); + let measure = |s: &str, size: f64| { + measured.set(measured.get() + s.len()); + helvetica_width(s, size) + }; + let text = format!("{} {}", "word ".repeat(5_000), "x".repeat(5_000)); + let lines = wrap_with(&text, 1.0, 13_000.0, measure); + assert_eq!(lines.len(), 2, "the words fit on one line, the long word on another"); + assert!(measured.get() <= 4 * text.len(), "{} bytes measured", measured.get()); + let narrow = wrap_with(&text, 10.0, 100.0, helvetica_width); + assert!(narrow.iter().all(|l| helvetica_width(l, 10.0) <= 100.0), "{narrow:?}"); + } } From fcedb156893891c718fca03dd27c9a04ba5ae110 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:36:48 +0000 Subject: [PATCH 182/517] fix(export): find font size and colour slots through a map The body-size tally and the RTF colour table searched every slot seen so far for each block, and the RTF writer searched the colour table again for every paragraph and cell, so a document with many distinct sizes or colours made export quadratic. Both keep their first-seen order and look slots up in a hash map. --- crates/export/src/lib.rs | 75 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index f3030ad58d..8c3999712f 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -8,6 +8,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::collections::HashMap; + mod pptx; mod zip; @@ -85,14 +87,33 @@ enum Item<'a> { PageBreak, } +#[cfg(test)] +thread_local! { + /// Entries read to find a font size's or a colour's slot, for the tests that keep it constant. + static LOOKUP_STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` entries read by a lookup (tests only). +#[inline] +fn count_lookup_steps(_n: usize) { + #[cfg(test)] + LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n))); +} + /// The body text size: the size most of the document's characters are set in. fn body_size(pages: &[Page]) -> f64 { + // Sizes in first-seen order (which breaks ties), and where each one is in it. let mut by: Vec<(i64, usize)> = Vec::new(); + let mut slot: HashMap = HashMap::new(); for b in pages.iter().flat_map(|p| &p.blocks) { let k = (b.size * 2.0).round() as i64; - match by.iter_mut().find(|e| e.0 == k) { - Some(e) => e.1 += b.text.len(), - None => by.push((k, b.text.len())), + count_lookup_steps(1); + let i = *slot.entry(k).or_insert_with(|| { + by.push((k, 0)); + by.len() - 1 + }); + if let Some(e) = by.get_mut(i) { + e.1 = e.1.saturating_add(b.text.len()); } } by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0) @@ -831,9 +852,12 @@ pub fn rtf(pages: &[Page]) -> String { let items = items(pages); // The colour table: entry 0 is the default (auto) colour, then each non-black colour once. let mut colors: Vec<[u8; 3]> = Vec::new(); + let mut slot: HashMap<[u8; 3], usize> = HashMap::new(); let mut note = |c: [f64; 3]| { let c = rgb8(c); - if c != [0, 0, 0] && !colors.contains(&c) { + count_lookup_steps(1); + if c != [0, 0, 0] && !slot.contains_key(&c) { + slot.insert(c, colors.len()); colors.push(c); } }; @@ -847,7 +871,8 @@ pub fn rtf(pages: &[Page]) -> String { // `\cfN` for a colour (nothing for black, which keeps the default). let cf = |c: [f64; 3]| { let c = rgb8(c); - colors.iter().position(|e| *e == c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) + count_lookup_steps(1); + slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) }; let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); if !colors.is_empty() { @@ -911,6 +936,46 @@ pub fn rtf(pages: &[Page]) -> String { mod tests { use super::*; + /// A page of `n` one-word blocks, each in its own size and colour. + fn varied_page(n: usize) -> Page { + let blocks = (0..n) + .map(|i| Block { + text: "word".into(), + rect: [72.0, i as f64, 100.0, i as f64 + 1.0], + size: 4.0 + i as f64 * 0.5, + bold: false, + italic: false, + color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5], + rotation: 0.0, + }) + .collect(); + Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None } + } + + fn lookup_steps(f: impl FnOnce()) -> usize { + LOOKUP_STEPS.with(|c| c.set(0)); + f(); + LOOKUP_STEPS.with(|c| c.get()) + } + + #[test] + fn many_distinct_sizes_are_counted_in_constant_steps_each() { + let pages = [varied_page(3000)]; + let steps = lookup_steps(|| { + body_size(&pages); + }); + assert!(steps <= 2 * 3000, "{steps} steps"); + } + + #[test] + fn many_distinct_colours_take_constant_steps_each() { + let pages = [varied_page(3000)]; + let mut out = String::new(); + let steps = lookup_steps(|| out = rtf(&pages)); + assert!(steps <= 4 * 3000, "{steps} steps"); + assert!(out.contains("\\cf3000 ") || out.contains("\\cf3000\\"), "the last colour has the last slot"); + } + fn page() -> Page { let b = |t: &str, y: f64, size: f64, bold: bool| Block { text: t.into(), From dba51fc8237dd04b5cd594d5fa56106e902d74b8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:37:39 +0000 Subject: [PATCH 183/517] fix(optimize): refuse a JPEG whose own size differs from its dictionary's The pixel reader capped the image size the dictionary gives, then decoded the JPEG at the size its header gives and compared afterwards, so a small image dictionary over a huge JPEG allocated the huge one first. The reader takes the size from the JPEG header and refuses a mismatch before decoding. --- crates/optimize/src/images.rs | 40 ++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 6182b08922..1d8a59e253 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -227,6 +227,19 @@ fn is_dct(s: &Stream) -> Option { } } +#[cfg(test)] +thread_local! { + /// Pixels decoded from JPEG data, for the test that keeps a mismatched JPEG from decoding. + static DECODED_PIXELS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` decoded pixels (tests only). +#[inline] +fn count_decoded(_n: u64) { + #[cfg(test)] + DECODED_PIXELS.with(|c| c.set(c.get().saturating_add(_n))); +} + /// 8-bit samples of an image (`n` components), decoded. pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?); @@ -235,7 +248,15 @@ pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { } let bytes = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)?; if is_dct(s)? { - let img = image::load_from_memory_with_format(&s.raw, image::ImageFormat::Jpeg).ok()?; + use image::ImageDecoder as _; + // The JPEG's own size is what gets decoded: one other than the dictionary's is refused from + // its header, before any pixels are. + let decoder = image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(&s.raw[..])).ok()?; + if decoder.dimensions() != (w, h) { + return None; + } + let img = image::DynamicImage::from_decoder(decoder).ok()?; + count_decoded(u64::from(img.width()) * u64::from(img.height())); // Adobe CMYK/YCCK JPEGs aren't handled (the colour space check excludes them). let v = if n == 1 { img.into_luma8().into_raw() } else { img.into_rgb8().into_raw() }; (v.len() == bytes).then_some(v) @@ -432,4 +453,21 @@ mod dimension_tests { let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]); assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4])); } + + /// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded. + #[test] + fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() { + let data = jpeg(&vec![128; 1000 * 1000], 1, 1000, 1000, 50).unwrap_or_default(); + let stream = |w: i64, h: i64| { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(w)); + dict.set(b"Height".to_vec(), Object::Int(h)); + dict.set(b"Filter".to_vec(), Object::name("DCTDecode")); + Stream::from_raw(dict, data.clone()) + }; + DECODED_PIXELS.with(|c| c.set(0)); + assert!(pixels(&stream(10, 10), 1).is_none()); + assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded"); + assert_eq!(pixels(&stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000)); + } } From 6b62585339b351d1bf8827377aa01117d5a1b4f7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:39:09 +0000 Subject: [PATCH 184/517] fix(optimize): bound the search for fragment runs on a page A run of images that is not one picture was searched again from each of its groups, collecting the rest of the run every time, so many images drawn over one another made a page quadratic. The search reads at most 16 groups per content operator (at least 65 536) and leaves the rest of the page as it is once that is spent. --- crates/optimize/src/fragments.rs | 55 ++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index caf617f717..44e1655460 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -29,6 +29,19 @@ pub const MIN_FRAGMENTS: usize = 4; /// Most pixels a merged image may have (memory). const MAX_PIXELS: u64 = 80_000_000; +/// Fragment groups a page's search for runs may read per content operator, and at least. A run +/// that is not one picture is searched again from each of its groups, so without a limit images +/// drawn over one another take time quadratic in their number; past it, the rest of the page is +/// left as it is. +const SCAN_PER_OP: usize = 16; +const MIN_SCAN: usize = 1 << 16; + +#[cfg(test)] +thread_local! { + /// Fragment groups read, for the test that keeps a page's search linear in its content. + static GROUP_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// One fragment: its image, its box in the coordinates the run is drawn in, and its size. struct Fragment { image: ObjRef, @@ -40,6 +53,8 @@ struct Fragment { /// The ops `q`, one or more `cm`, `Do`, `Q` starting at `i`: the index after them, the matrix /// and the name drawn. fn group_at(ops: &[Op], i: usize) -> Option<(usize, Matrix, Vec)> { + #[cfg(test)] + GROUP_READS.with(|c| c.set(c.get().saturating_add(1))); if !ops.get(i)?.is("q") { return None; } @@ -226,7 +241,13 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: & let ops = parsed.ops; let mut out: Vec = Vec::with_capacity(ops.len()); let (mut i, mut merged, mut k) = (0, 0, 0); + let budget = ops.len().saturating_mul(SCAN_PER_OP).max(MIN_SCAN); + let mut scanned = 0usize; while i < ops.len() { + if scanned > budget { + out.extend(ops.get(i..).unwrap_or_default().iter().cloned()); + break; + } // The longest run of fragment groups from here with one colour space. let mut run = Vec::new(); let mut kind: Option<(Object, usize)> = None; @@ -245,10 +266,12 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: & run.push(Fragment { image, rect: [e, f, e + a, f + d], width, height }); j = next; } + scanned = scanned.saturating_add(run.len()).saturating_add(1); // Not one picture as a whole: the rows drawn one under the other from the start may be. if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) { run = drawn_in_sequence(&ops, i, &xobjects, doc); j = run_end(&ops, i, run.len()); + scanned = scanned.saturating_add(run.len().saturating_mul(2)).saturating_add(2); } let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else { // Not a run: keep this op and look again from the next one. @@ -304,6 +327,8 @@ pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, pro #[cfg(test)] mod tests { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + use super::*; #[test] @@ -318,4 +343,34 @@ mod tests { ]; assert!(merge(&Document::new_empty(), &run, 3).is_none()); } + + /// Many images drawn on top of each other are not one picture; looking for runs among them reads + /// a bounded number of groups per operator, not the rest of the page from every group. + #[test] + fn rejected_runs_read_a_bounded_number_of_groups() { + let mut doc = Document::new_empty(); + let mut image = Dict::new(); + for (k, v) in + [("Subtype", Object::name("Image")), ("Width", Object::Int(1)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))] + { + image.set(k.as_bytes().to_vec(), v); + } + image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0]))); + let groups = 4000; + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(groups).as_bytes()))); + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(image)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut page = Dict::new(); + page.set(b"Contents".to_vec(), Object::Ref(content)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + let page = doc.add(Object::Dict(page)); + GROUP_READS.with(|c| c.set(0)); + let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true); + assert_eq!(merged.ok(), Some(0)); + let reads = GROUP_READS.with(|c| c.get()); + assert!(reads <= 40 * 4 * groups, "{reads} group reads"); + } } From 3e968c8aa24adfb729c6d45a3c98abf5fed07027 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:36:35 +0000 Subject: [PATCH 185/517] chore(a11y, fonts): satisfy clippy in the CID width ranges and a test helper --- crates/a11y/src/tests.rs | 2 +- crates/fonts/src/pdf.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index a24f59edb2..ad57477364 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1033,7 +1033,7 @@ fn mark_reads(f: impl FnOnce()) -> usize { /// Page content nesting `depth` unmarked `BMC` blocks around a marked one, with `ops` operators inside. fn deeply_nested_page(doc: &mut Document, depth: usize, ops: usize) -> pdfcraft_cos::Dict { use pdfcraft_cos::{Dict, Stream}; - let mut data = "/Figure << /MCID 0 >> BDC ".repeat(1); + let mut data = "/Figure << /MCID 0 >> BDC ".to_string(); data.push_str(&"/X BMC ".repeat(depth)); data.push_str(&"0 0 1 1 re f ".repeat(ops)); data.push_str(&"EMC ".repeat(depth + 1)); diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 4a118a07d3..3ddd16a9b4 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -714,10 +714,10 @@ impl Metrics { i += 2; } Some(c1) => { - if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) { - if ranges.len() < MAX_CMAP_ENTRIES { - ranges.push((c0, c1 as u32, v)); - } + if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) + && ranges.len() < MAX_CMAP_ENTRIES + { + ranges.push((c0, c1 as u32, v)); } i += 3; } From 5856c12eb3748ff1e1be936695aa8014896a08c2 Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:55:44 -0300 Subject: [PATCH 186/517] fix(sign): judge a page's indirect /Annots array by the annotations it gains A page whose /Annots is an object of its own keeps its dictionary when a later revision adds a signature widget or a comment: only the array changes. That array fell through to "other changes", so countersigning such a document (as many signing services do) turned every earlier signature invalid, including PdfCraft's own second signature. A changed array that was a signed page's /Annots is now classified like an inline /Annots: by the annotations it gained (signature, form fill or comments), under the same DocMDP rules. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/sign/src/pdf.rs | 12 +++++++++++ crates/sign/tests/it/pdf.rs | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 82a801dee8..cd555a9893 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1265,6 +1265,11 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod // attacker-written version carries (relabelling a signed page /Type /Sig, or dropping its // /Type and adding /ModDate, must not reclassify it away from page content). let signed_pages: HashSet = pdfcraft_annot::page_refs(&old).unwrap_or_default().into_iter().collect(); + // The signed pages' /Annots arrays that are objects of their own. Adding an annotation updates + // such an array in place (the page dictionary does not change), so it is judged like a + // page's inline /Annots: by the annotations it gained. + let signed_annots: HashSet = + signed_pages.iter().filter_map(|pg| old.get(*pg).as_dict().and_then(|d| d.get(b"Annots").and_then(Object::as_ref))).collect(); // What each signed page draws from, by the role it held when signed (#1787). let signed_resources = signed_page_resources(&old, &signed_pages); let old_info = old.trailer().get(b"Info").and_then(Object::as_ref); @@ -1333,6 +1338,13 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod (Some(k), _) => k, // The Document Security Store's own arrays and dictionaries, when they only grew. (_, _) if dss.contains(&num) => kind::SECURITY_STORE, + // A signed page's indirect /Annots array, still an array: what it gained. + (_, Object::Array(now)) if signed_annots.contains(&r) && before.as_deref().is_some_and(|b| b.as_array().is_some()) => { + let was: HashSet = + before.as_deref().and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_ref).collect()).unwrap_or_default(); + let added: Vec = now.iter().filter_map(Object::as_ref).filter(|r| !was.contains(r)).collect(); + added_annots(doc, &added) + } (_, Object::Dict(d)) => { let was = before.as_deref().and_then(Object::as_dict); if signed_pages.contains(&r) { diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 61517b53d3..cd0b713a8c 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -139,6 +139,47 @@ fn signing_an_existing_field_and_counter_signing() { )); } +/// A page whose /Annots is an object of its own: signing and commenting update that array in +/// place, not the page. A second signature is a signature change for the first, as with an +/// inline /Annots, and a comment is still a comment (refused under a form-fill certification). +#[test] +fn an_indirect_annots_array_is_judged_by_the_annotations_it_gains() { + let page = pdfcraft_cos::ObjRef { num: 3, generation: 0 }; + let unsigned = edit_after(&fixture(), |doc| { + let annots = doc.add(Object::Array(vec![Object::Ref(pdfcraft_cos::ObjRef { num: 6, generation: 0 })])); + doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Ref(annots))).unwrap(); + }); + let annots_ref = |bytes: &[u8]| open(bytes).get(page).as_dict().and_then(|d| d.get(b"Annots").and_then(Object::as_ref)); + let annots = annots_ref(&unsigned).unwrap(); + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + let first = pdfcraft_sign::sign(&open(&unsigned), &id, &opts()).unwrap(); + let second = pdfcraft_sign::sign(&open(&first), &id, &SignOptions { rect: Some([20.0, 160.0, 220.0, 210.0]), ..opts() }).unwrap(); + assert_eq!(annots_ref(&second), Some(annots), "the signer kept the page's array indirect"); + let doc = open(&second); + let sigs = signatures(&doc, &second, &trust); + let s = sigs.iter().find(|s| s.field == "Signature1").unwrap(); + assert_eq!(s.modification, Modification::Allowed(vec![kind::SIGNATURE]), "{:?}", s.details); + assert_eq!(s.status, Status::Valid); + // A comment appended to the indirect array. + let comment = |doc: &mut Document| { + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Text")); + d.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(10), Object::Int(10), Object::Int(30), Object::Int(30)])); + let r = doc.add(Object::Dict(d)); + let mut a = doc.get(annots).as_array().cloned().unwrap(); + a.push(Object::Ref(r)); + doc.set(annots, Object::Array(a)); + }; + let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap(); + let s = check(&edit_after(&first, comment)); + assert_eq!((s.status, s.modification), (Status::Valid, Modification::Allowed(vec![kind::COMMENTS]))); + let certified = pdfcraft_sign::sign(&open(&unsigned), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap(); + let s = check(&edit_after(&certified, comment)); + assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::COMMENTS]))); +} + /// Issue #1328: a signature field whose /V is null, directly or through a reference, is unsigned: /// it is listed as unsigned and can be signed by name. #[test] From 4ac5165a8671cbf37283ebd41faeabdc6a3e066b Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:57:31 -0300 Subject: [PATCH 187/517] fix(sign): let an LTV update declare the ESIC extension in the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PAdES LTV writers add the /DSS and, in the same revision, declare the ESIC developer extension in the catalog's /Extensions (ISO 32000-2 §7.12). Any catalog change outside a short key list was "document structure", so the signature such an update protects showed as invalid. /Extensions may now gain declarations after signing; one that was signed may still not change or go. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/sign/src/pdf.rs | 18 +++++++++++++++++- crates/sign/tests/it/pdf.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 82a801dee8..1e03c6ff3f 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1685,6 +1685,18 @@ fn page_change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Di } } +/// Whether the catalog `d` keeps every developer extension the signed catalog `b` declared, each +/// with the same value: it may only declare more. +fn extensions_kept(doc: &Document, old: &Document, d: &Dict, b: &Dict) -> bool { + let now = d.get(b"Extensions").map(|e| doc.resolve(e)).and_then(|e| e.as_dict().cloned()); + let was = b.get(b"Extensions").map(|e| old.resolve(e)).and_then(|e| e.as_dict().cloned()); + match (was, now) { + (None, _) => true, + (Some(was), Some(now)) => was.iter().all(|(k, v)| now.get(k) == Some(v)), + (Some(_), None) => false, + } +} + fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) -> &'static str { let ty = d.name(b"Type"); // A later revision cannot make an existing object a signature by relabelling it: a signature @@ -1694,9 +1706,13 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) // Only a new signature value or policy: rewriting a signed one is not a signature (#1788). Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") if before.is_none() => return kind::SIGNATURE, Some(b"Catalog") => { - let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering"]; + let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering", b"Extensions"]; return match before { Some(b) if without(d, keys) != without(b, keys) => kind::STRUCTURE, + // Developer extensions (ISO 32000-2 §7.12) only declare what the file uses. A + // writer adding a /DSS also declares the ESIC extension it belongs to, so a + // declaration may be added, but none that was signed may change or go. + Some(b) if !extensions_kept(doc, old, d, b) => kind::STRUCTURE, Some(b) => { if d.get(b"AcroForm") != b.get(b"AcroForm") { // Compare the forms themselves, inline or not. diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 61517b53d3..0b0f91654e 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -544,6 +544,35 @@ fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() { assert_eq!(check(&doc, &tampered, &trusted).status, Status::Invalid); } +#[test] +fn ltv_writers_may_declare_the_esic_extension_but_not_change_a_signed_one() { + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap(); + let ext = |base: &[u8], level: i64| { + let mut esic = pdfcraft_cos::Dict::new(); + esic.set(b"BaseVersion".to_vec(), Object::name("1.7")); + esic.set(b"ExtensionLevel".to_vec(), Object::Int(level)); + let mut e = pdfcraft_cos::Dict::new(); + e.set(b"ESIC".to_vec(), Object::Dict(esic)); + edit_after(base, |doc| { + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(e))).unwrap(); + }) + }; + // As PAdES LTV writers save it: the store and the ESIC declaration in one revision. + let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); + let evidence = pdfcraft_sign::dss::Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }; + let ltv = pdfcraft_sign::dss::embed(&open(&ext(&signed, 2)), &evidence).unwrap(); + let s = check(<v); + assert_eq!(s.status, Status::Valid, "{:?}", s.details); + assert!(matches!(&s.modification, Modification::Allowed(k) if !k.contains(&kind::STRUCTURE)), "{:?}", s.modification); + // A declaration that was signed may not change afterwards. + let signed_with = pdfcraft_sign::sign(&open(&ext(&fixture(), 2)), &id, &opts()).unwrap(); + let s = check(&ext(&signed_with, 3)); + assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::STRUCTURE]))); +} + #[test] fn embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid() { use pdfcraft_sign::dss::{self, Evidence}; From f42fb0ad49bc729fbfa8ec492dae592f8ec89bed Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:43:01 +0000 Subject: [PATCH 188/517] fix(forms): a deleted field leaves the calculation order Deleting a field took it off its pages and out of the field tree but left its entry in the form's /CO array. That reference kept the field dictionary, its value and its widgets reachable, so a full save after applying redactions over the field still wrote the redacted value. --- crates/forms/src/author.rs | 25 +++++++++++++++++ crates/redact/src/tests.rs | 1 + .../redact/src/tests/surviving_references.rs | 28 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 crates/redact/src/tests/surviving_references.rs diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index f5178a6879..544d0a7f8b 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1280,6 +1280,31 @@ pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { } } } + // Out of the calculation order too: a `/CO` entry would keep the field, its value and its + // widgets reachable for a full save. + drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) +} + +/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or +/// by reference. +fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> { + let Some(root) = doc.root() else { return Ok(()) }; + let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; + let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) }; + let Some(co_obj) = af.get(b"CO").cloned() else { return Ok(()) }; + let co = doc.resolve(&co_obj).as_array().cloned().unwrap_or_default(); + let kept: Vec = co.iter().filter(|o| !o.as_ref().is_some_and(&gone)).cloned().collect(); + if kept.len() == co.len() { + return Ok(()); + } + match (co_obj.as_ref(), af_obj.as_ref()) { + (Some(r), _) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), + (_, Some(af_ref)) => doc.update_dict(af_ref, |d| d.set(b"CO".to_vec(), Object::Array(kept)))?, + (_, None) => { + af.set(b"CO".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + } Ok(()) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index a9902495df..52d80eb26e 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2462,6 +2462,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } mod property_retirement; +mod surviving_references; /// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still /// draws only the one immediately before it (LASTARR, 7 glyphs, 35 pt wide from x = 10) -- not diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs new file mode 100644 index 0000000000..cfe6d87bbe --- /dev/null +++ b/crates/redact/src/tests/surviving_references.rs @@ -0,0 +1,28 @@ +//! Redacted content must leave the file: no other reference may keep it reachable by a full save. + +use super::*; + +/// Whether `secret` survives a full save of `doc`, in the file's bytes or in a decoded stream. +fn full_save_holds(doc: &Document, secret: &[u8]) -> bool { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let found = |data: &[u8]| data.windows(secret.len()).any(|w| w == secret); + if found(&bytes) { + return true; + } + let saved = Document::open(Arc::new(bytes)).unwrap(); + saved.scan_objects().into_iter().any(|(_, o)| matches!(&*o, Object::Stream(s) if s.decoded().is_ok_and(|d| found(&d)))) +} + +#[test] +fn a_redacted_field_listed_in_the_calculation_order_leaves_the_saved_file() { + let mut doc = one_page(b"", "", vec![]); + pdfcraft_forms::add_field(&mut doc, 0, [10.0, 50.0, 100.0, 70.0], &pdfcraft_forms::NewField::Text { multiline: false }, Some("ssn")).unwrap(); + pdfcraft_forms::set_value(&mut doc, "ssn", &pdfcraft_forms::FieldValue::Text("SSN-078-05-1120".into())).unwrap(); + let field = pdfcraft_forms::fields(&doc).swap_remove(0).obj; + let root = doc.root().unwrap(); + let af = doc.get(root).as_dict().unwrap().reference(b"AcroForm").unwrap(); + doc.update_dict(af, |d| d.set(b"CO".to_vec(), Object::Array(vec![Object::Ref(field)]))).unwrap(); + mark(&mut doc, 0, &[[0.0, 40.0, 120.0, 80.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().fields, 1); + assert!(!full_save_holds(&doc, b"SSN-078-05-1120"), "the field's value is still reachable"); +} From 6ca4431b50a21760c7c9c9378b9d01a05acf140f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:23 +0000 Subject: [PATCH 189/517] fix(redact): check every input content stream, whatever it calls itself Both checks for text left under applied redaction marks (redaction's own verify pass and the engine's renderer cross-check) skipped any page content stream whose dictionary said /PCMark /Redaction, so that the overlay's label would not count. An input file could put that label on its own content stream and keep it out of both checks. Applying redactions now records the overlay streams it adds (stamp returns the new stream), and only those are skipped. --- crates/edit/src/lib.rs | 15 +++++++++------ crates/engine/src/lib.rs | 15 ++++++++++----- crates/engine/src/tests.rs | 34 +++++++++++++++++++++++++++++++++- crates/redact/src/lib.rs | 16 ++++++++-------- 4 files changed, 60 insertions(+), 20 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index d3db00fe30..bef515f981 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -17,7 +17,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, Object, Stream}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; #[derive(Debug, thiserror::Error, PartialEq)] @@ -601,7 +601,7 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation { /// Put a mark's content on a page: behind (with the behind-marks at the start) or on top /// (appended, after wrapping the original content in q/Q). fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec, behind: bool) -> Result<(), EditError> { - place_tagged(doc, page, kind.tag(), content, behind) + place_tagged(doc, page, kind.tag(), content, behind).map(drop) } /// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the @@ -609,7 +609,8 @@ fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, conten /// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of /// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content /// sequences are closed at the end too, so hidden optional content cannot hide the new mark. -fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result<(), EditError> { +/// Returns the new content stream. +fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result { let mut list = contents(doc, page)?; // The behind-marks at the start: backgrounds in the order added, then watermarks. let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()]; @@ -625,7 +626,8 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont Isolation::Unknown { unclosed, marked } => (behind_marks < list.len()).then_some((behind_marks, unclosed, marked)), } }; - let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content)))); + let mark_ref = doc.add(Object::Stream(Stream::flate(tagged(tag), &content))); + let mark = Object::Ref(mark_ref); if behind { // Right after the backgrounds already there, so a new background is painted over the // older ones (#3591) and a watermark over all of them. Not after the watermarks: on a @@ -646,7 +648,7 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont list.push(mark); } doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; - Ok(()) + Ok(mark_ref) } /// Put content tagged `tag` first inside the q/Q wrapper, so it applies to the page's original @@ -664,7 +666,8 @@ fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Page, tag: &s /// Draw `content` on top of a page (0-based) as permanent page content, with standard Helvetica /// available as `/PCHelv`. The stream is tagged `tag` (not a mark kind, so Remove never takes /// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes. -pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec) -> Result<(), EditError> { +/// Returns the new content stream. +pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec) -> Result { let all = page_list(doc); check(&[page], all.len())?; add_resources(doc, &all[page], None, Some(&content))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 817eb79a96..32d3a1f15b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -989,12 +989,13 @@ fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { /// that it reads differently), a mark drawn over the text as displayed lies over glyphs the /// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is /// what the user saw when placing the marks, so it is asked. `after` is written as a save would -/// write it, without the marks' own overlay (the box and its label are drawn on purpose), its +/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its /// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps /// the document as it was. fn shown_under_redactions( after: &pdfcraft_cos::Document, marks: &[pdfcraft_redact::Mark], + overlays: &[pdfcraft_cos::ObjRef], pages: &[pdfcraft_render::PageInfo], password: Option<&str>, ) -> Result<(), EditError> { @@ -1011,8 +1012,7 @@ fn shown_under_redactions( for &pi in &marked { let Some(page) = model.get(pi) else { continue }; let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; - let kept: Vec = - list.into_iter().filter(|o| shown.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")).collect(); + let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; } let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; @@ -2356,6 +2356,9 @@ struct EditCtx { /// Fields the edit renamed to keep imported names separate (old → new), for the repair /// log and the undo label. renamed_fields: Vec<(String, String)>, + /// The overlay streams the edit's applied redactions added, which alone the renderer's check + /// leaves out. + redaction_overlays: Vec, } impl EditCtx { @@ -2382,6 +2385,7 @@ impl EditCtx { seed, count: 0, renamed_fields: Vec::new(), + redaction_overlays: Vec::new(), } } @@ -2736,7 +2740,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; } Edit::ApplyRedactions { pages } => { - pdfcraft_redact::apply(doc, pages.as_deref())?; + cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; } Edit::ClearRedactions => { pdfcraft_redact::clear_marks(doc, None)?; @@ -3798,6 +3802,7 @@ impl Session { cx.js = Some(js::JsRunner::new(&next, &name)); } cx.renamed_fields.clear(); + cx.redaction_overlays.clear(); // `next` is a copy: if the edit fails or crashes, the document is unchanged. guard(|| run_edit(&mut next, &edit, &mut cx)) .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; @@ -3806,7 +3811,7 @@ impl Session { if let Edit::ApplyRedactions { pages } = &edit { let marks: Vec = pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); - guard(|| shown_under_redactions(&next, &marks, &doc.info.pages, doc.password.as_deref())) + guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; } // Renamed fields are said, not slipped in: a repair-log note (with the caveat that diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3d347e6492..958204bc53 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2253,12 +2253,17 @@ fn setting_the_calculation_order_recalculates_and_undoes() { /// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what /// the page's /Resources holds. fn one_line_page(resources: &str, body: &str) -> Vec { + one_line_page_with(resources, "", body) +} + +/// [`one_line_page`] with `stream_entries` added to the content stream's dictionary. +fn one_line_page_with(resources: &str, stream_entries: &str, body: &str) -> Vec { let objs = [ "<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(), format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> /Contents 5 0 R >>"), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), - format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + format!("<< /Length {} {stream_entries} >>\nstream\n{body}\nendstream", body.len()), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); @@ -2316,6 +2321,33 @@ fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() { assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}"); } +/// Only the overlay a redaction adds is left out of the renderer's check. A page whose own content +/// stream calls itself a redaction overlay (`/PCMark /Redaction`) is checked like any other. +#[test] +fn a_content_stream_labelled_as_a_redaction_overlay_is_still_checked() { + let line = "Public words then SECRET42 and more"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let forged = one_line_page_with( + "/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", + "/PCMark /Redaction", + &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET"), + ); + let id = s.open("forged.pdf", None, Arc::new(forged), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET42"); + let applied = s.apply(id, Edit::ApplyRedactions { pages: None }); + if applied.is_ok() { + assert!(!page_texts(&s, id).concat().contains("SECRET42"), "the page still shows the redacted word"); + let saved = s.save_bytes(id).unwrap(); + let held = |d: &[u8]| d.windows(8).any(|w| w == b"SECRET42"); + assert!(!held(&saved), "the saved file holds the redacted word"); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!( + !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), + "a saved stream holds the redacted word" + ); + } +} + #[test] fn redaction_marks_apply_for_good_and_undo() { let (mut s, id) = session_with(2); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 968773b91d..97d5bcd9fe 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -143,6 +143,10 @@ pub struct Report { /// The form was an AcroForm+XFA form and its `/XFA` packets (which still held redacted field /// values, and from which a relayout could rebuild them) were removed. pub xfa_removed: bool, + /// The content streams this apply added to draw the marks' boxes and labels. They are the + /// only streams a check for text left under the marks may skip: an input stream labelled + /// like an overlay (`/PCMark /Redaction`) is checked like any other. + pub overlays: Vec, } fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { @@ -576,7 +580,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); let (list, data) = page_streams(doc, &page.dict, pi)?; - // The overlay stream (last) draws text of its own: the overlay label is allowed. - let original: Vec> = list - .iter() - .zip(data) - .filter(|(o, _)| doc.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")) - .map(|(_, d)| d) - .collect(); + // The overlay stream draws text of its own: the overlay label is allowed. + let original: Vec> = + list.iter().zip(data).filter(|(o, _)| !o.as_ref().is_some_and(|r| report.overlays.contains(&r))).map(|(_, d)| d).collect(); let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut scratch = Report::default(); let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch); From 22aa6d5f9afb7339a19fa55b792b96f7c7910c37 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:50:59 +0000 Subject: [PATCH 190/517] fix(redact): place glyphs with the inherited and ExtGState-set text state The redaction interpreter started every form XObject with an empty text state (no font, size 0, no spacing) although a form inherits its caller's graphics state, and it ignored the /Font entry of an ExtGState set by gs. Text shown that way was placed at the wrong positions, so removal and the verify pass could both miss glyphs under a mark. Forms now start from the caller's state, and gs sets the font and size as Tf does. The engine's renderer cross-check test used the gs case as its example of interpreter/renderer disagreement; it now runs the check directly on an unredacted page, which also covers an input stream labelled as a redaction overlay. --- crates/engine/src/lib.rs | 2 +- crates/engine/src/tests.rs | 78 +++++++++++++-------------- crates/redact/src/interp.rs | 51 ++++++++++++++++-- crates/redact/src/tests.rs | 1 + crates/redact/src/tests/text_state.rs | 27 ++++++++++ 5 files changed, 111 insertions(+), 48 deletions(-) create mode 100644 crates/redact/src/tests/text_state.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 32d3a1f15b..f257865d3a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -985,7 +985,7 @@ fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { /// /// Redaction decides what lies under a mark from the positions its own interpreter computes, and /// its verify pass reuses them. Where they differ from the renderer's (a standard font without -/// /Widths, whose advances the interpreter approximates; a CMap, or a font set outside `Tf`, +/// /Widths, whose advances the interpreter approximates; a CMap /// that it reads differently), a mark drawn over the text as displayed lies over glyphs the /// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is /// what the user saw when placing the marks, so it is asked. `after` is written as a save would diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 958204bc53..8937cf6780 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2294,58 +2294,52 @@ fn mark_shown_text(s: &mut Session, id: DocId, needle: &str) { s.apply(id, mark).unwrap(); } -/// Redaction removes text by the positions its own interpreter works out. Here the font and size -/// come from an ExtGState /Font entry (`gs`), which the renderer honours and the interpreter does -/// not model, so it places no glyph under the mark the user drew over the word as displayed: it -/// used to report success and leave the word in the file. The renderer is asked now, and the edit -/// is refused. +/// Redaction removes text by the positions its own interpreter works out, and the renderer has +/// the last word: where the two disagree, a page that still shows a glyph mostly under a mark +/// fails the edit. The check is run here on a page nothing was removed from, as if the +/// interpreter had placed the word elsewhere. Only the overlay streams an apply added are left +/// out: a content stream of the input labelled like one (`/PCMark /Redaction`) is checked too. #[test] -fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() { +fn the_renderer_check_refuses_text_still_shown_under_a_mark() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page_with("/Font << /F1 4 0 R >>", "/PCMark /Redaction", "BT /F1 16 Tf 20 150 Td (Public words then SECRET42) Tj ET"); + let id = s.open("shown.pdf", None, Arc::new(page), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET42"); + let doc = s.get(id).unwrap(); + let mut cos = doc.editor.as_ref().unwrap().cos.clone(); + let marks = pdfcraft_redact::marks(&cos); + let refused = |cos: &pdfcraft_cos::Document| { + let shown = shown_under_redactions(cos, &marks, &[], &doc.info.pages, None); + matches!(shown, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0) + }; + assert!(refused(&cos)); + // The page's contents as an array, as they are once an apply has added its overlay. + let page = pdfcraft_model::pages(&cos).swap_remove(0); + let contents = page.dict.get(b"Contents").cloned().unwrap(); + cos.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Array(vec![contents]))).unwrap(); + assert!(refused(&cos), "the labelled input stream was not checked"); +} + +/// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer +/// draws it, so a mark drawn over it as displayed takes it out of the file. +#[test] +fn a_word_in_a_font_set_by_an_extgstate_is_redacted() { let line = "Public words then SECRET42 and more"; let mut s = Session::new().with_clock(|| 1_700_000_000); let by_gs = one_line_page("/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET")); let id = s.open("gs.pdf", None, Arc::new(by_gs), None).unwrap(); mark_shown_text(&mut s, id, "SECRET42"); - let refused = s.apply(id, Edit::ApplyRedactions { pages: None }); - assert!(matches!(refused, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0), "{refused:?}"); - // Nothing was changed: the mark is still pending and the page still shows the line. - assert_eq!(s.get(id).unwrap().redaction_marks(), 1); - assert_eq!(page_texts(&s, id), [line]); - - // The same line with its font set by `Tf`: the mark is applied and the word is gone. - let by_tf = one_line_page("/Font << /F1 4 0 R >>", &format!("BT /F1 16 Tf 20 150 Td ({line}) Tj ET")); - let id = s.open("tf.pdf", None, Arc::new(by_tf), None).unwrap(); - mark_shown_text(&mut s, id, "SECRET42"); s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); let shown = page_texts(&s, id).swap_remove(0); assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}"); -} - -/// Only the overlay a redaction adds is left out of the renderer's check. A page whose own content -/// stream calls itself a redaction overlay (`/PCMark /Redaction`) is checked like any other. -#[test] -fn a_content_stream_labelled_as_a_redaction_overlay_is_still_checked() { - let line = "Public words then SECRET42 and more"; - let mut s = Session::new().with_clock(|| 1_700_000_000); - let forged = one_line_page_with( - "/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", - "/PCMark /Redaction", - &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET"), + let saved = s.save_bytes(id).unwrap(); + let held = |d: &[u8]| d.windows(8).any(|w| w == b"SECRET42"); + assert!(!held(&saved), "the saved file holds the redacted word"); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!( + !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), + "a saved stream holds the redacted word" ); - let id = s.open("forged.pdf", None, Arc::new(forged), None).unwrap(); - mark_shown_text(&mut s, id, "SECRET42"); - let applied = s.apply(id, Edit::ApplyRedactions { pages: None }); - if applied.is_ok() { - assert!(!page_texts(&s, id).concat().contains("SECRET42"), "the page still shows the redacted word"); - let saved = s.save_bytes(id).unwrap(); - let held = |d: &[u8]| d.windows(8).any(|w| w == b"SECRET42"); - assert!(!held(&saved), "the saved file holds the redacted word"); - let reopened = pdfcraft_cos::Document::open(saved).unwrap(); - assert!( - !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), - "a saved stream holds the redacted word" - ); - } } #[test] diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index a608568f38..14dedbb344 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -690,6 +690,34 @@ fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Ok(()) } +/// The font, writing direction and size an ExtGState's `/Font [font size]` entry sets, as `Tf` +/// would (ISO 32000-2 §8.4.5); `None` when `name` sets no font. The metrics are cached with the +/// fonts `Tf` names, under a key no resource name can take (a name cannot hold a null byte). +fn extgstate_font( + doc: &Document, + scope: &mut Scope<'_>, + ext_gstates: &Dict, + name: &[u8], + fallback: &Rc, +) -> Option<(Rc, bool, f64)> { + let state = doc.resolve(ext_gstates.get(name)?).as_dict().cloned()?; + let entry = doc.resolve(state.get(b"Font")?).as_array().cloned()?; + let [font, size] = entry.as_slice() else { return None }; + let size = doc.resolve(size).as_f64()?; + let font_dict = doc.resolve(font).as_dict().cloned(); + let vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + let key = [b"\0".as_slice(), name].concat(); + let metrics = match scope.fonts.get(&key) { + Some(m) => m.clone(), + None => { + let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); + scope.fonts.insert(key, m.clone()); + m + } + }; + Some((metrics, vertical, size)) +} + /// One glyph of a shown string. struct Glyph { bytes: std::ops::Range, @@ -700,11 +728,9 @@ struct Glyph { /// Process a scope's content streams (state flows from one stream to the next). pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, ctm: Matrix) -> Output { - let mut out = Output::default(); - let fallback = Rc::new(Metrics::fallback()); - let mut gs = Gs { + let gs = Gs { ctm, - font: fallback.clone(), + font: Rc::new(Metrics::fallback()), vertical: false, size: 0.0, char_spacing: 0.0, @@ -714,9 +740,19 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< leading: 0.0, rise: 0.0, }; + process_from(doc, scope, streams, resources, gs) +} + +/// [`process`] from graphics state `gs`: a form XObject starts with its caller's, text state +/// included (ISO 32000-2 §8.10.1), so text it shows without setting a font of its own is placed +/// with the caller's font, size and spacing, as the renderer places it. +fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, mut gs: Gs) -> Output { + let mut out = Output::default(); + let fallback = Rc::new(Metrics::fallback()); let mut stack: Vec = Vec::new(); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let fonts_res = res_dict(doc, resources, b"Font"); + let ext_gstates = res_dict(doc, resources, b"ExtGState"); let mut xobjects = res_dict(doc, resources, b"XObject"); // The current path: its operators, its bounding box in user space, and whether it clips. let mut path: Vec = Vec::new(); @@ -865,6 +901,11 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< }; } } + b"gs" => { + if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &ext_gstates, n, &fallback)) { + (gs.font, gs.vertical, gs.size) = (font, vertical, size); + } + } b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, @@ -1223,7 +1264,7 @@ fn xobject( let res = own.clone().unwrap_or_else(|| resources.clone()); let saved_fonts = std::mem::take(&mut scope.fonts); scope.depth += 1; - let inner = process(doc, scope, std::slice::from_ref(&data), &res, fctm); + let inner = process_from(doc, scope, std::slice::from_ref(&data), &res, Gs { ctm: fctm, ..gs.clone() }); scope.depth -= 1; scope.fonts = saved_fonts; out.residue += inner.residue; diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 52d80eb26e..882cc50aeb 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2463,6 +2463,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { mod property_retirement; mod surviving_references; +mod text_state; /// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still /// draws only the one immediately before it (LASTARR, 7 glyphs, 35 pt wide from x = 10) -- not diff --git a/crates/redact/src/tests/text_state.rs b/crates/redact/src/tests/text_state.rs new file mode 100644 index 0000000000..4e888f262c --- /dev/null +++ b/crates/redact/src/tests/text_state.rs @@ -0,0 +1,27 @@ +//! Glyphs are placed with the whole text state, wherever it was set. + +use super::*; + +const SECRET_AT_10_100: [f64; 4] = [5.0, 95.0, 45.0, 110.0]; + +#[test] +fn a_font_set_by_an_extgstate_places_its_glyphs() { + let mut doc = one_page(b"BT /GS1 gs 10 100 Td (SECRET) Tj ET", "/ExtGState << /GS1 << /Type /ExtGState /Font [5 0 R 10] >> >>", vec![]); + // "ET" spans x 30–40. + assert_eq!(under(&mut doc, 0, &[[30.0, 95.0, 40.0, 110.0]]), 2); + mark(&mut doc, 0, &[SECRET_AT_10_100], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + assert!(!content(&doc, 0).contains("SECRET")); +} + +#[test] +fn a_form_inherits_the_text_state_of_the_page_that_draws_it() { + // The form shows text with the font, size and spacing the page set before `Do`. + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT 10 100 Td (SECRET) Tj ET"); + let mut doc = one_page(b"/F1 10 Tf 1 Tc /Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + // Glyphs 5 pt wide, 1 pt apart: the last one spans x 40–45. + assert_eq!(under(&mut doc, 0, &[[40.0, 95.0, 46.0, 110.0]]), 1); + mark(&mut doc, 0, &[SECRET_AT_10_100], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.glyphs, r.forms_rewritten), (6, 1), "{r:?}"); +} From be64a60c876402f43ffef893f1fd87ac77786981 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:52:12 +0000 Subject: [PATCH 191/517] fix(redact): unlink removed XObjects from surviving forms' resources After applying redactions, removed images and forms were unlinked from page-tree resource dictionaries but not from the resources of forms the page still draws. A form sharing the page's original resource dictionary (a common producer layout) kept the removed image reachable, so a full save wrote it. The walk over surviving forms now drops XObject entries for removed objects that the form's own content does not draw, and fails closed when that cannot be determined. --- crates/redact/src/interp.rs | 34 +++++++++++++--- .../redact/src/tests/surviving_references.rs | 40 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 14dedbb344..fca3a1da88 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -529,8 +529,9 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_prope } } // A surviving form can own the same old resource dictionary as a rewritten page. Its - // unused descriptions remain reachable too, even after every page holder is cleaned. - unlink_form_properties(doc, &holder_nodes, retired_properties)?; + // unused descriptions and doomed XObjects remain reachable too, even after every page + // holder is cleaned. + unlink_from_forms(doc, &holder_nodes, &doomed, retired_properties)?; Ok(()) } @@ -567,8 +568,11 @@ fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], c Ok(pruned) } -fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Object]) -> Result<(), CosError> { - if retired.is_empty() { +/// Unlink retired properties and `doomed` XObjects from the resources of the forms reachable from +/// the page-tree `holders`, each unless the form's own content (or a form inheriting its +/// resources) still uses the name. Fails closed when that cannot be determined. +fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet, retired: &[Object]) -> Result<(), CosError> { + if retired.is_empty() && doomed.is_empty() { return Ok(()); } let mut pending: Vec = holders @@ -593,7 +597,27 @@ fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Obj let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; let names: Vec> = res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect(); - if !names.is_empty() && prune_retired_properties(doc, &mut res, &names, &[vec![form.decoded()?]])? { + let mut xo = res_dict(doc, &res, b"XObject"); + let doomed_names: Vec> = + xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); + if names.is_empty() && doomed_names.is_empty() { + pending.push(res); + continue; + } + let data = form.decoded()?; + let mut pruned = false; + for name in doomed_names { + match content_draws(doc, std::slice::from_ref(&data), &xo, &name, 0, &mut HashSet::new()) { + Some(true) => {} + Some(false) => pruned |= xo.remove(&name).is_some(), + None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }), + } + } + if pruned { + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + pruned |= prune_retired_properties(doc, &mut res, &names, &[vec![data]])?; + if pruned { doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; } pending.push(res); diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index cfe6d87bbe..2c8bdb09c2 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -26,3 +26,43 @@ fn a_redacted_field_listed_in_the_calculation_order_leaves_the_saved_file() { assert_eq!(apply(&mut doc, None).unwrap().fields, 1); assert!(!full_save_holds(&doc, b"SSN-078-05-1120"), "the field's value is still reachable"); } + +/// A surviving form shares the page's resource dictionary, which lists the image the mark +/// removes; the form never draws it. +#[test] +fn a_redacted_image_does_not_survive_in_a_surviving_forms_resources() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 7 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Font << /F1 5 0 R >> /XObject << /Im 8 0 R /Fm 9 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 7 0 R", b"BT /F1 10 Tf 10 200 Td (Public) Tj ET"), + ]); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(content(&doc, 0).contains("Do"), "the form is still drawn"); + assert!(!full_save_holds(&doc, b"IMAGESECRET"), "the removed image is still reachable"); +} + +/// The same form drawing the image itself keeps it: the page still shows it there. +#[test] +fn an_image_a_surviving_form_draws_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 7 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Font << /F1 5 0 R >> /XObject << /Im 8 0 R /Fm 9 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 7 0 R", b"q 50 0 0 50 200 200 cm /Im Do Q"), + ]); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(full_save_holds(&doc, b"IMAGESECRET"), "the image the form still draws outside the mark"); +} From cccd435a5308bd628976e0f0d9923f1eebf90e36 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:53:24 +0000 Subject: [PATCH 192/517] fix(redact): bound image decoding by the declared raster size Clearing the pixels of a partly covered image decoded its stream with the global 1 GiB allowance and only then cut it to the raster length its Width, Height and bit depth declare. A small Flate stream could make every such image allocate and inflate far more than it can use. Decoding now stops at twice the raster plus a byte per row (filter-chain and predictor overhead) and a little slack; an image past that is removed whole, as an undecodable one already is. --- crates/redact/src/image.rs | 6 +++++- crates/redact/src/tests.rs | 1 + crates/redact/src/tests/limits.rs | 25 +++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 crates/redact/src/tests/limits.rs diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 9a19ba2f0b..601700f257 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -77,7 +77,11 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] let bytes = row.checked_mul(h)?; let row_bits = row.checked_mul(8)?; bytes.checked_mul(8)?; - let mut data = s.decoded().ok()?; + // Decode no further than the raster can need: a filter stage may legitimately produce up to + // twice it (ASCIIHex text under Flate) plus a PNG predictor's byte per row. Data past that + // can only be a decompression bomb; the image is then removed whole. + let allowance = bytes.checked_add(h)?.checked_mul(2)?.checked_add(4096)?; + let mut data = s.decoded_within(allowance).ok()?; if data.len() < bytes { return None; } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 882cc50aeb..94081f9e35 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2461,6 +2461,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } } +mod limits; mod property_retirement; mod surviving_references; mod text_state; diff --git a/crates/redact/src/tests/limits.rs b/crates/redact/src/tests/limits.rs new file mode 100644 index 0000000000..6bee96aed4 --- /dev/null +++ b/crates/redact/src/tests/limits.rs @@ -0,0 +1,25 @@ +//! Work and memory stay bounded by what the page declares, whatever the file holds. + +use super::*; + +/// A half-covered 4×1 grey image whose Flate data decodes to `decoded`. +fn half_covered_image(decoded: &[u8]) -> Report { + let dict = "/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + let mut doc = one_page(b"q 40 0 0 10 100 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, b"")]); + let Object::Stream(s) = &*doc.get(ObjRef::new(7, 0)) else { panic!() }; + doc.set(ObjRef::new(7, 0), Object::Stream(Stream::flate(s.dict.clone(), decoded))); + mark(&mut doc, 0, &[[95.0, 95.0, 120.0, 115.0]], ""); + apply(&mut doc, None).unwrap() +} + +#[test] +fn an_image_decoding_far_past_its_raster_is_removed_without_decoding_it_all() { + let r = half_covered_image(&[255; 4]); + assert_eq!((r.images_removed, r.images_cleared), (0, 1), "{r:?}"); + // 4 bytes of raster, then 4 MiB the image never uses: decoding stops at a bound derived from + // the raster, and the image goes whole. + let mut bomb = vec![255; 4]; + bomb.resize(4 << 20, 0); + let r = half_covered_image(&bomb); + assert_eq!((r.images_removed, r.images_cleared), (1, 0), "{r:?}"); +} From 2cdb39503ef303b5cc66703abda470d044896690 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:55:22 +0000 Subject: [PATCH 193/517] fix(redact): compare marked content without a copy per enclosing MCID Finding which marked content a redaction changed copied every operator into the buffer of each MCID enclosing it, so content nested n deep was held n times: a modest stream of nested BDCs could exhaust memory. Each operator inside marked content is now serialized once, and each MCID keeps the ranges its sequences span. The bytes compared are capped; MCIDs past the cap count as changed, so their descriptions are cleared rather than kept. --- crates/redact/src/tags.rs | 88 +++++++++++++++++++++++++------ crates/redact/src/tests/limits.rs | 30 +++++++++++ 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index ab5735e065..8548810600 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -6,15 +6,49 @@ //! references to marked content that is now empty are dropped from the element. use std::collections::{HashMap, HashSet}; +use std::ops::Range; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::RedactError; -/// Each MCID's operators (serialized), and whether anything is still drawn inside it. -fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap, bool)> { - let mut out: HashMap, bool)> = HashMap::new(); - let mut stack: Vec> = Vec::new(); +/// The marked content of one content stream. Every operator inside some MCID is serialized once +/// into `ops`, and each MCID holds the ranges of `ops` its sequences span (marked-content +/// operators are not written, so a sequence is one range however deeply it nests), with whether +/// anything is drawn inside it. Copying the operators into every enclosing MCID instead would +/// hold `depth` copies of nested content. +#[derive(Default)] +struct Marked { + ops: Vec, + ids: HashMap>, bool)>, +} + +impl Marked { + /// The bytes of `ranges`, which are ranges of `ops`. + fn bytes<'a>(&'a self, ranges: &'a [Range]) -> impl Iterator + 'a { + ranges.iter().flat_map(|r| self.ops.get(r.clone()).unwrap_or_default()) + } +} + +/// Most bytes [`touched`] compares for one stream. Nested sequences share their operators, so the +/// comparisons can add up to the content's size times its nesting depth; past this, the MCIDs +/// left are taken as changed (their descriptions go) rather than compared. +const MAX_COMPARED: usize = 1 << 28; + +fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> Marked { + let mut out = Marked::default(); + // Open sequences: the MCID with where its content starts in `ops` and the drawing operators + // written before it, or `None` for a sequence without one. + let mut stack: Vec> = Vec::new(); + let (mut open_ids, mut drawn) = (0usize, 0usize); + let close = |out: &mut Marked, open: Option<(i64, usize, usize)>, drawn: usize| { + if let Some((id, start, drawn_before)) = open { + let end = out.ops.len(); + let e = out.ids.entry(id).or_default(); + e.0.push(start..end); + e.1 |= drawn > drawn_before; + } + }; for op in pdfcraft_content::parse(data).ops { match op.op.as_slice() { b"BDC" => { @@ -28,9 +62,10 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap None, }; if let Some(id) = id { - out.entry(id).or_default(); + out.ids.entry(id).or_default(); + open_ids += 1; } - stack.push(id); + stack.push(id.map(|id| (id, out.ops.len(), drawn))); continue; } b"BMC" => { @@ -38,11 +73,17 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap { - stack.pop(); + if let Some(open) = stack.pop() { + open_ids = open_ids.saturating_sub(usize::from(open.is_some())); + close(&mut out, open, drawn); + } continue; } _ => {} } + if open_ids == 0 { + continue; + } let draws = match op.op.as_slice() { b"Tj" | b"'" | b"\"" => op.operands.last().and_then(Object::as_string).is_some_and(|s| !s.bytes.is_empty()), b"TJ" => { @@ -51,13 +92,12 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap true, _ => false, }; - let mut bytes = Vec::new(); - pdfcraft_content::write_op(&op, &mut bytes); - for id in stack.iter().flatten() { - let e = out.entry(*id).or_default(); - e.0.extend_from_slice(&bytes); - e.1 |= draws; - } + pdfcraft_content::write_op(&op, &mut out.ops); + drawn += usize::from(draws); + } + // Sequences the stream leaves open run to its end. + while let Some(open) = stack.pop() { + close(&mut out, open, drawn); } out } @@ -67,10 +107,17 @@ pub(crate) fn touched(doc: &Document, resources: &Dict, before: &[u8], after: &[ let (b, a) = (by_mcid(doc, resources, before), by_mcid(doc, resources, after)); let mut changed = HashSet::new(); let mut empty = HashSet::new(); - for (id, (ops, _)) in &b { - match a.get(id) { + let mut budget = MAX_COMPARED; + let len = |ranges: &[Range]| ranges.iter().map(|r| r.len()).fold(0usize, usize::saturating_add); + for (id, (old, _)) in &b.ids { + match a.ids.get(id) { Some((new, draws)) => { - if new != ops { + let n = len(old); + let same = n == len(new) && n <= budget && { + budget -= n; + b.bytes(old).eq(a.bytes(new)) + }; + if !same { changed.insert(*id); } if !draws { @@ -413,3 +460,10 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet } Ok(n) } + +/// Bytes held for comparing marked content, for tests that bound it. +#[cfg(test)] +pub(crate) fn retained_for_comparison(doc: &Document, resources: &Dict, data: &[u8]) -> usize { + let marked = by_mcid(doc, resources, data); + marked.ops.len() + marked.ids.values().map(|(ranges, _)| ranges.len() * std::mem::size_of::>()).sum::() +} diff --git a/crates/redact/src/tests/limits.rs b/crates/redact/src/tests/limits.rs index 6bee96aed4..8519e08608 100644 --- a/crates/redact/src/tests/limits.rs +++ b/crates/redact/src/tests/limits.rs @@ -23,3 +23,33 @@ fn an_image_decoding_far_past_its_raster_is_removed_without_decoding_it_all() { let r = half_covered_image(&bomb); assert_eq!((r.images_removed, r.images_cleared), (1, 0), "{r:?}"); } + +/// Marked content nested `depth` deep, each level with its own MCID, around `depth` drawing +/// operators. +fn nested_marked_content(depth: usize) -> Vec { + let mut data = Vec::new(); + for id in 0..depth { + data.extend_from_slice(format!("/P <> BDC\n").as_bytes()); + } + data.extend(b"0 0 1 1 re f\n".repeat(depth)); + data.extend(b"EMC\n".repeat(depth)); + data +} + +#[test] +fn comparing_deeply_nested_marked_content_holds_each_operator_once() { + let doc = one_page(b"", "", vec![]); + let data = nested_marked_content(2000); + let held = tags::retained_for_comparison(&doc, &Dict::new(), &data); + assert!(held <= 2 * data.len(), "{held} bytes held for {} bytes of content", data.len()); +} + +#[test] +fn a_change_inside_nested_marked_content_touches_every_enclosing_mcid() { + let doc = one_page(b"", "", vec![]); + let before = b"/P <> BDC /P <> BDC BT (A) Tj ET EMC 0 0 1 1 re f EMC /P <> BDC BT (B) Tj ET EMC"; + let after = b"/P <> BDC /P <> BDC BT ET EMC 0 0 1 1 re f EMC /P <> BDC BT (B) Tj ET EMC"; + let (changed, empty) = tags::touched(&doc, &Dict::new(), before, after); + assert_eq!(changed, HashSet::from([0, 1])); + assert_eq!(empty, HashSet::from([1])); +} From 7db37911ed7f22f623f5a9416aa1e83ebe053adc Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:57:39 +0000 Subject: [PATCH 194/517] fix(redact): clear descriptions above removed content and annotations Structure cleanup cleared alternate text, actual text and expansions only on the element that directly owned changed marked content, and an element that lost an annotation reference kept its descriptions unless it lost every kid. An ancestor's /ActualText covering a redacted span, or a link element's /Alt repeating the address of a removed link, stayed in the saved file. Elements that lose an annotation now always lose their descriptions, and every ancestor of an affected element (found through both /P and the tree's /K, which may disagree) loses its own. An element written in place above one with a description to clear refuses the operation, as other in-place cases already do. --- crates/redact/src/tags.rs | 135 ++++++++++++++---- crates/redact/src/tests.rs | 1 + crates/redact/src/tests/descriptions.rs | 64 +++++++++ .../redact/src/tests/surviving_references.rs | 2 +- 4 files changed, 177 insertions(+), 25 deletions(-) create mode 100644 crates/redact/src/tests/descriptions.rs diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index 8548810600..60c158b180 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -183,20 +183,21 @@ pub(crate) fn clean_within( // also from the parent tree and the ID tree: one listed only there still owns marked content // (#3302). The stack pops /K first, so an element reached through /K gets the page it inherits // from its ancestors before a tree reaches it without one. - let mut todo: Vec<(Object, Option)> = - [&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None)).collect(); + let mut todo: Vec<(Object, Option, Above)> = + [&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None, Above::default())).collect(); let mut seen = HashSet::new(); let mut pages = HashMap::new(); + let mut parents = Parents::new(); let mut hits: Vec<(ObjRef, Dict, Option, bool)> = Vec::new(); // Whether marked content `x` (a kid on page `pg`) is one this cleanup must change. let touches = |doc: &Document, x: &Object, pg: Option| { mcid_of(doc, x, pg, page, stream).is_some_and(|m| stream.is_some() || changed.contains(&m) || empty.contains(&m)) }; - while let Some((k, pg)) = todo.pop() { + while let Some((k, pg, above)) = todo.pop() { match k { - Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg))), + Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg, above))), // A number or name tree node written in place. - Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))), + Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg, above))), // An element written in place: its kids are followed, but if it owns marked content // this cleanup changes, only rewriting what holds it could change it. Object::Dict(d) => { @@ -208,9 +209,11 @@ pub(crate) fn clean_within( if kids.iter().any(|x| touches(doc, x, pg)) { return Err(RedactError::Tags); } - todo.extend(kids.into_iter().map(|x| (x, pg))); + let above = above.through(&d); + todo.extend(kids.into_iter().map(|x| (x, pg, above))); } Object::Ref(r) => { + parents.record(r, above); if !seen.insert(r) { continue; } @@ -219,13 +222,13 @@ pub(crate) fn clean_within( } let object = doc.get(r); if let Object::Array(a) = &*object { - todo.extend(a.iter().cloned().map(|x| (x, pg))); + todo.extend(a.iter().cloned().map(|x| (x, pg, above))); continue; } let Some(d) = object.as_dict().cloned() else { continue }; if !d.contains(b"S") { // A parent tree or ID tree node: its values may be elements. - todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))); + todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg, above))); continue; } let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) { @@ -238,11 +241,12 @@ pub(crate) fn clean_within( if sensitive || remap { hits.push((r, d.clone(), pg, sensitive)); } - todo.extend(kids.into_iter().map(|x| (x, pg))); + todo.extend(kids.into_iter().map(|x| (x, pg, Above::element(r)))); } _ => {} } } + let sensitive: Vec = hits.iter().filter(|h| h.3).map(|h| h.0).collect(); let mut n = 0; for (r, d, pg, sensitive) in hits { let kids = d.get(b"K").map(|k| doc.resolve(k)); @@ -291,9 +295,88 @@ pub(crate) fn clean_within( })?; n += usize::from(sensitive && doc.get(r).as_dict() != Some(&d)); } + // An element above may describe the changed content too (`/ActualText` on a span's parent + // covers the span, §14.9.4). + n += clear_ancestors(doc, &sensitive, &parents, limit)?; Ok(n) } +/// Where the walk reached an object from: the nearest element above it that is an object of its +/// own, and whether an element written in place between them carries a description (which only +/// rewriting the object holding it could remove). +#[derive(Clone, Copy, Default)] +struct Above { + element: Option, + described_in_place: bool, +} + +impl Above { + fn element(r: ObjRef) -> Self { + Self { element: Some(r), described_in_place: false } + } + + /// Below an element written in place. + fn through(self, d: &Dict) -> Self { + Self { described_in_place: self.described_in_place || DESCRIPTIONS.iter().any(|k| d.contains(k)), ..self } + } +} + +/// The keys of an element that describe its content. +const DESCRIPTIONS: [&[u8]; 3] = [b"Alt", b"ActualText", b"E"]; + +/// Each element's parents as the walk found them: through `/K`, which `/P` may not agree with. +struct Parents(HashMap>); + +impl Parents { + fn new() -> Self { + Self(HashMap::new()) + } + + fn record(&mut self, r: ObjRef, above: Above) { + if above.element.is_some() || above.described_in_place { + self.0.entry(r).or_default().push(above); + } + } +} + +/// Remove the descriptions of every element above `elements`, through both `/P` and the walk's +/// `parents`; returns how many elements changed. [`RedactError::Tags`] when an element written in +/// place above them carries one, or when there are more than `limit` ancestors. +fn clear_ancestors(doc: &mut Document, elements: &[ObjRef], parents: &Parents, limit: usize) -> Result { + let mut todo: Vec = elements.to_vec(); + let mut seen: HashSet = elements.iter().copied().collect(); + let mut cleared = 0; + while let Some(r) = todo.pop() { + let mut up: Vec = doc.get(r).as_dict().and_then(|d| d.get(b"P")).and_then(Object::as_ref).into_iter().collect(); + for above in parents.0.get(&r).into_iter().flatten() { + if above.described_in_place { + return Err(RedactError::Tags); + } + up.extend(above.element); + } + for a in up { + if !seen.insert(a) { + continue; + } + if seen.len() > limit { + return Err(RedactError::Tags); + } + // The structure tree root (no /S) ends the climb. + let Some(d) = doc.get(a).as_dict().filter(|d| d.contains(b"S")).cloned() else { continue }; + if DESCRIPTIONS.iter().any(|k| d.contains(k)) { + doc.update_dict(a, |e| { + for key in DESCRIPTIONS { + e.remove(key); + } + })?; + cleared += 1; + } + todo.push(a); + } + } + Ok(cleared) +} + /// The page an element inherits (§14.7.4.4): the nearest `/Pg` among its ancestors, following /// `/P`. `Ok(None)` when none has one; [`RedactError::Tags`] when the lookup gives up (more than /// [`MAX_PARENTS`] ancestors, or a loop), which isn't the same as having no page. `pages` keeps @@ -384,12 +467,14 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet }; // Elements are reached from the root's /K, and also from the parent tree and the ID tree: // one listed only there (left behind by an editor, say) still points at its annotation. - let mut todo: Vec = [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).collect(); + let mut todo: Vec<(Object, Above)> = + [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, Above::default())).collect(); let mut seen = HashSet::new(); + let mut parents = Parents::new(); let mut hits: Vec = Vec::new(); - while let Some(k) = todo.pop() { + while let Some((k, above)) = todo.pop() { match k { - Object::Array(a) => todo.extend(a), + Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, above))), // A structure element written in place: its kids are followed; one that tags a // removed annotation can't be changed here (see above). Object::Dict(d) if d.contains(b"S") => { @@ -397,11 +482,13 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet if kids.iter().any(|x| refers_to(doc, x, gone)) { return Err(RedactError::Tags); } - todo.extend(kids); + let above = above.through(&d); + todo.extend(kids.into_iter().map(|x| (x, above))); } // A number or name tree node written in place (§7.9.6, §7.9.7): its values and kids. - Object::Dict(d) => todo.extend(tree_node_entries(&d)), + Object::Dict(d) => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, above))), Object::Ref(r) => { + parents.record(r, above); if !seen.insert(r) { continue; } @@ -410,26 +497,26 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet } let object = doc.get(r); if let Object::Array(a) = &*object { - todo.extend(a.iter().cloned()); + todo.extend(a.iter().cloned().map(|x| (x, above))); continue; } let Some(d) = object.as_dict() else { continue }; if !d.contains(b"S") { // A parent tree or ID tree node: its values may be elements. - todo.extend(tree_node_entries(d)); + todo.extend(tree_node_entries(d).into_iter().map(|x| (x, above))); continue; } let kids = kids_of(doc, d); if kids.iter().any(|x| refers_to(doc, x, gone)) { hits.push(r); } - todo.extend(kids); + todo.extend(kids.into_iter().map(|x| (x, Above::element(r)))); } _ => {} } } let mut n = 0; - for r in hits { + for &r in &hits { let Some(d) = doc.get(r).as_dict().cloned() else { continue }; let kids = d.get(b"K").map(|k| doc.resolve(k)); let array = kids.as_ref().is_some_and(|k| matches!(&**k, Object::Array(_))); @@ -449,15 +536,15 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet } else { e.remove(b"K"); } - // An element left with nothing to tag keeps no text describing what it tagged: - // a link's alternate text often repeats the URL, an annotation's its comment. - if keep.is_empty() { - for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] { - e.remove(key); - } + // An element that lost an annotation keeps no text describing it, even with other + // kids left: a link's alternate text often repeats the URL, an annotation's its + // comment. Elements above it may describe it too. + for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] { + e.remove(key); } })?; } + clear_ancestors(doc, &hits, &parents, limit)?; Ok(n) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 94081f9e35..ebc95f4519 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2461,6 +2461,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } } +mod descriptions; mod limits; mod property_retirement; mod surviving_references; diff --git a/crates/redact/src/tests/descriptions.rs b/crates/redact/src/tests/descriptions.rs new file mode 100644 index 0000000000..df6b8555dc --- /dev/null +++ b/crates/redact/src/tests/descriptions.rs @@ -0,0 +1,64 @@ +//! Descriptions in the structure tree (alternate text, actual text, expansions) go with the +//! content they describe, on the element that owns it and on every element above it. + +use super::surviving_references::full_save_holds; +use super::*; + +/// A tagged page: "SECRET" (MCID 0) inside a section whose descriptions repeat it, and "Public" +/// (MCID 1). `paragraph_parent` is the secret paragraph's /P. +fn sectioned(paragraph_parent: &str) -> Document { + let content = b"/P <> BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 10 100 Td (Public) Tj ET EMC"; + pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 7 0 R /MarkInfo << /Marked true >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", content), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Type /StructTreeRoot /K 8 0 R >>".to_vec(), + b"<< /S /Document /P 7 0 R /K [11 0 R 10 0 R] >>".to_vec(), + format!("<< /S /P /P {paragraph_parent} /Pg 3 0 R /K 0 >>").into_bytes(), + b"<< /S /P /P 8 0 R /Pg 3 0 R /ActualText (Public) /K 1 >>".to_vec(), + b"<< /S /Sect /P 8 0 R /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /K 9 0 R >>".to_vec(), + ]) +} + +#[test] +fn an_ancestor_describing_redacted_content_loses_its_descriptions() { + // The paragraph names its section as parent, or (wrongly) the document: the section is found + // either way, by its /K. + for parent in ["11 0 R", "8 0 R"] { + let mut doc = sectioned(parent); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + assert!(!full_save_holds(&doc, b"LEAK_"), "a description of the redacted word survived (paragraph /P {parent})"); + let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap(); + assert!(public.contains(b"ActualText"), "an element beside it keeps its own"); + } +} + +#[test] +fn a_link_element_that_keeps_its_text_loses_the_description_of_its_removed_annotation() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] /StructParents 0 >>" + .to_vec(), + stream("", b"/Link << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"), + FONT.replace("95 0 R", "12 0 R").into_bytes(), + b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://example.org/) >> >>".to_vec(), + b"null".to_vec(), + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [11 0 R] 1 11 0 R] >> /ParentTreeNextKey 2 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K 10 0 R >>".to_vec(), + b"<< /Type /StructElem /S /P /P 9 0 R /ActualText (See LEAK_PARENT) /K 11 0 R >>".to_vec(), + // The link element tags its text and the annotation; its descriptions repeat the address. + b"<< /Type /StructElem /S /Link /P 10 0 R /Pg 3 0 R /Alt (LEAK_ALT) /ActualText (LEAK_ACTUAL) /E (LEAK_E) /T (LEAK_T) /K [0 << /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + widths(), + ]); + let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap(); + assert!(done.contains(&(Hidden::LinksActionsScripts, 1)), "{done:?}"); + assert!(!full_save_holds(&doc, b"LEAK_"), "a description of the removed link survived"); + let link = doc.get(ObjRef::new(11, 0)).as_dict().cloned().unwrap(); + assert_eq!(link.get(b"K"), Some(&Object::Array(vec![Object::Int(0)])), "the element keeps its text"); +} diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 2c8bdb09c2..d3aa98af67 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -3,7 +3,7 @@ use super::*; /// Whether `secret` survives a full save of `doc`, in the file's bytes or in a decoded stream. -fn full_save_holds(doc: &Document, secret: &[u8]) -> bool { +pub(super) fn full_save_holds(doc: &Document, secret: &[u8]) -> bool { let bytes = write_full(doc, &SaveOptions::default()).unwrap(); let found = |data: &[u8]| data.windows(secret.len()).any(|w| w == secret); if found(&bytes) { From b72078871a307ad8d28b9e3f861885435fe10834 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:09:57 +0000 Subject: [PATCH 195/517] fix(content): end inline images where the renderer ends them Filtered inline image data has no length, so its end is an EI chosen among candidates, and the content parser chose by a different rule than the renderer's tokenizer (hayro-syntax): the renderer also takes an EI with no whitespace before it, and checks a candidate by reading what follows as operators. With image data such as "7FEI 1 0 0 1 200 0 cm", the renderer ran a cm that redaction read as image data, so text was drawn 200 pt from where removal and its verify pass looked. The parser now takes each inline image's end from the renderer's tokenizer (already in the tree through pdfcraft-cos), so both read the same operators after the image; a test runs both on the same fixtures. Only where that tokenizer reads no image (no EI it accepts, or parameters it can't read) does the parser fall back to its own rules, counting the image in Parsed::ambiguous_images, and applying redactions refuses such a page as unreadable. --- Cargo.lock | 1 + crates/content/Cargo.toml | 3 + crates/content/src/lib.rs | 120 ++++++++++++++++++----- crates/content/src/tests.rs | 58 +++++++++-- crates/engine/src/tests.rs | 24 +++++ crates/redact/src/interp.rs | 6 ++ crates/redact/src/lib.rs | 6 ++ crates/redact/src/tests.rs | 1 + crates/redact/src/tests/inline_images.rs | 34 +++++++ 9 files changed, 224 insertions(+), 29 deletions(-) create mode 100644 crates/redact/src/tests/inline_images.rs diff --git a/Cargo.lock b/Cargo.lock index a950149243..e318b7a77c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4042,6 +4042,7 @@ version = "0.11.0" name = "pdfcraft-content" version = "0.11.0" dependencies = [ + "hayro-syntax", "pdfcraft-cos", ] diff --git a/crates/content/Cargo.toml b/crates/content/Cargo.toml index c553df0b8c..e6b4c28a42 100644 --- a/crates/content/Cargo.toml +++ b/crates/content/Cargo.toml @@ -10,3 +10,6 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } +# The renderer's content tokenizer, already in the tree through pdfcraft-cos: inline images end +# where the renderer ends them. +hayro-syntax = { workspace = true } diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index eb126ffa21..097ec26897 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -60,6 +60,10 @@ pub struct Parsed { pub ops: Vec, /// Bytes that could not be read as operands or operators. pub skipped: usize, + /// Inline images the renderer's tokenizer reads no image for (it stops, or finds no `EI` it + /// accepts). Their end here is a guess, so the operators after them may not be the ones the + /// page draws. + pub ambiguous_images: usize, /// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before /// and whether one runs out of it into the next. Only boundaries are recorded, so the size /// depends on the number of pieces, not of comments. @@ -142,19 +146,27 @@ fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed { start.get_or_insert(at); operands.push(Object::Null); } - b"BI" => match inline_image(data, lx.pos) { - Ok((dict, img, end)) => { - lx.pos = end; - out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end }); - operands.clear(); + b"BI" => { + let image = inline_image(data, at, lx.pos); + out.ambiguous_images += usize::from(image.ambiguous); + let end = image.end; + lx.pos = end; + match image.image { + Some((dict, img)) => { + out.ops.push(Op { + op: b"BI".to_vec(), + operands: Vec::new(), + inline: Some((dict, img)), + span: start.take().unwrap_or(at)..end, + }); + } + None => { + out.skipped += end.saturating_sub(at); + start = None; + } } - Err(end) => { - out.skipped += end.saturating_sub(at); - lx.pos = end; - start = None; - operands.clear(); - } - }, + operands.clear(); + } _ => { let s = start.take().unwrap_or(at); out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos }); @@ -198,11 +210,29 @@ fn comment_edges(data: &[u8], trivia: std::ops::Range, ends: &[usize], bo } } -/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds -/// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read. -fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize> { +/// An inline image as [`inline_image`] reads it. +struct Inline { + /// Its parameters and data; `None` when the parameters can't be read. + image: Option<(Dict, Vec)>, + /// The position after its `EI` (or the end of the stream). + end: usize, + /// The renderer's tokenizer reads no image here, so `end` is this parser's guess. + ambiguous: bool, +} + +/// Read the inline image whose `BI` keyword starts at `bi` (its parameters start at `pos`). +/// +/// Filtered image data has no length, and `EI` can occur inside it, so where it ends is a +/// heuristic choice among `EI` candidates. The renderer's choice decides which operators the +/// page draws after the image, and redaction must see the same ones, so the end is taken from +/// the renderer's tokenizer (hayro-syntax) rather than worked out here by another rule. Only +/// when that tokenizer reads no image does this parser fall back to its own rules, and the +/// image is marked ambiguous. +fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline { let mut lx = Lexer::new(data, pos); let mut dict = Dict::new(); + let mut readable = true; + let mut stop = pos; loop { lx.skip_ws(); if lx.eat_keyword(b"ID") { @@ -216,22 +246,68 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize let Some((k, v)) = entry else { // A stream that ends inside the parameters is cut short, not unreadable: keep what was read. if at >= data.len() { - return Ok((dict, Vec::new(), data.len())); + return Inline { image: Some((dict, Vec::new())), end: data.len(), ambiguous: false }; } - // Without an `EI` after it, the image runs to the end of the stream. - return Err(end_of_image(data, at, b"").map_or(data.len(), |(_, end)| end)); + readable = false; + stop = at; + break; }; dict.set(k, v); } + if let Some((start, end_data, end)) = renderer_image_end(data, bi) { + // Where the renderer ends the data at an `EI` found by its heuristic, one whitespace byte + // before that `EI` separates it from the data. + let mut samples = data.get(start..end_data).unwrap_or_default(); + if data.get(end_data..).is_some_and(|rest| rest.starts_with(b"EI")) + && let Some((last, rest)) = samples.split_last() + && is_ws(*last) + { + samples = rest; + } + return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: false }; + } + if !readable { + // Without an `EI` after it, the image runs to the end of the stream. + return Inline { image: None, end: end_of_image(data, stop, b"").map_or(data.len(), |(_, end)| end), ambiguous: true }; + } // One whitespace byte separates ID from the data. let begin = (lx.pos + 1).min(data.len()); let end = unfiltered_image_end(data, begin, &dict) .or_else(|| declared_image_end(data, begin, &dict)) .or_else(|| image_data_end(data, begin, filter_eod(&dict))); - Ok(match end { - Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end), - None => (dict, data[begin..].to_vec(), data.len()), - }) + let (image, end) = match end { + Some((end_data, end)) => (data.get(begin..end_data).unwrap_or_default().to_vec(), end), + None => (data.get(begin..).unwrap_or_default().to_vec(), data.len()), + }; + Inline { image: Some((dict, image)), end, ambiguous: true } +} + +/// Where the renderer's tokenizer ends the inline image whose `BI` starts at `bi`: the start and +/// end of its data and the position after its `EI`. `None` when it reads no image there (its +/// parameters don't read, or no `EI` passes its checks). +fn renderer_image_end(data: &[u8], bi: usize) -> Option<(usize, usize, usize)> { + use hayro_syntax::content::TypedIter; + use hayro_syntax::content::ops::{InlineImage, TypedInstruction}; + let tail = data.get(bi..)?; + let mut iter = TypedIter::new(tail); + let (start, len) = match iter.next()? { + TypedInstruction::InlineImage(InlineImage(stream)) => match stream.raw_data() { + // The data is a slice of `tail`: its offset is where it starts. + std::borrow::Cow::Borrowed(samples) => ((samples.as_ptr() as usize).checked_sub(tail.as_ptr() as usize)?, samples.len()), + std::borrow::Cow::Owned(_) => return None, + }, + _ => return None, + }; + let end_data = start.checked_add(len)?; + // `committed` is past the whitespace after `EI`; the image ends at the `EI`. + let mut end = iter.committed(); + while end > end_data && tail.get(end - 1).is_some_and(|b| is_ws(*b)) { + end -= 1; + } + if end < end_data.checked_add(2)? || tail.get(end - 2..end) != Some(b"EI") { + return None; + } + Some((bi.checked_add(start)?, bi.checked_add(end_data)?, bi.checked_add(end)?)) } /// The end-of-data marker (`>` for ASCIIHex, `~>` for ASCII85) of the outer stream filter, if any. diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 094a0665df..c9abef8349 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -102,10 +102,11 @@ fn unfiltered_inline_masks_default_to_one_bit_and_indexed_images_have_one_compon let parsed = inline_pixels(parameters.as_bytes(), b" EI "); assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI "); assert_eq!(parsed.ops.len(), 3); - // Indexed samples permit at most 8 bits, even though device spaces permit 16. + // Indexed samples permit at most 8 bits, even though device spaces permit 16, so the + // length is unknown, and the renderer takes the second `EI`. let invalid = parameters.replace("/W 4", "/W 2").replace("/BPC 8", "/BPC 16"); let parsed = inline_pixels(invalid.as_bytes(), b" EI "); - assert!(parsed.ops[1].inline.as_ref().unwrap().1.is_empty()); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI "); } } @@ -127,7 +128,7 @@ fn unfiltered_inline_image_terminator_must_follow_its_samples() { } #[test] -fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() { +fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them() { for parameters in [ b"/W 4 /H 1 /BPC 8 /CS /G /F /A85".as_slice(), b"/W 4 /H 1 /BPC 8 /CS /ResourceColor", @@ -142,12 +143,20 @@ fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() { b"/W 9223372036854775807 /H 9223372036854775807 /BPC 16 /CS /CMYK", b"/W 9223372036854775807 /H 2 /BPC 8 /CS /G", ] { + // What follows the first `EI` (`!!~>`) does not read as content, so the renderer takes + // the later one. let parsed = inline_pixels(parameters, b"! EI !!~>"); - assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"!", "{}", String::from_utf8_lossy(parameters)); + let label = String::from_utf8_lossy(parameters); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"! EI !!~>", "{label}"); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), [b"q".as_slice(), b"BI", b"Q"], "{label}"); + assert_eq!(parsed.ambiguous_images, 0, "{label}"); } - // A short payload still uses the tolerant recovery, including when there is no EI. - for src in [b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc"] { - assert_eq!(parse(src).ops[0].inline.as_ref().unwrap().1, b"abc"); + // A short payload is read whole; without an `EI`, the renderer reads no image and this + // parser's end is a guess. + for (src, ambiguous) in [(b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), 0), (b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc", 1)] { + let parsed = parse(src); + assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abc"); + assert_eq!(parsed.ambiguous_images, ambiguous); } } @@ -459,3 +468,38 @@ fn ascii85_inline_image_does_not_terminate_at_gt_ei_inside_payload() { assert_eq!(img, b"!!!!>EI !!~>"); } } + +/// The operators the renderer's tokenizer reads from `data`. +fn renderer_operators(data: &[u8]) -> Vec> { + let mut iter = hayro_syntax::content::UntypedIter::new(data); + let mut out = Vec::new(); + while let Some(instruction) = iter.next() { + out.push(instruction.operator.as_ref().to_vec()); + } + out +} + +/// Inline images end where the renderer ends them, so both read the same operators after one: +/// an `EI` without whitespace before it in the data, upper-case text ending in "EI" after the +/// image, and an `EI` inside compressed bytes (#3579). +#[test] +fn inline_images_end_where_the_renderer_ends_them() { + let flate: Vec = (0..FLATE_WITH_EI.len()).step_by(2).map(|i| u8::from_str_radix(&FLATE_WITH_EI[i..i + 2], 16).unwrap()).collect(); + let mut stored = b"q BI /W 64 /H 1 /BPC 8 /CS /G /F /Fl ID ".to_vec(); + stored.extend_from_slice(&flate); + stored.extend_from_slice(b"\nEI Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET"); + let fixtures: [&[u8]; 6] = [ + b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", + b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F> EI BT /F1 10 Tf 10 100 Td (ZWEI) Tj (EIN FREI BEI) Tj ET", + b"q BI /W 2 /H 1 /CS /G /BPC 8 /F /AHx ID 7F7F> EI Q BT (DREI) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 00> EI", + b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID ! EI !!~>\nEI Q", + b"q BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q", + &stored, + ]; + for data in fixtures { + let parsed = parse(data); + let ours: Vec> = parsed.ops.iter().map(|op| op.op.clone()).collect(); + assert_eq!(ours, renderer_operators(data), "{}", String::from_utf8_lossy(data)); + assert_eq!((parsed.skipped, parsed.ambiguous_images), (0, 0), "{}", String::from_utf8_lossy(data)); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8937cf6780..3012a1f3fe 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2320,6 +2320,30 @@ fn the_renderer_check_refuses_text_still_shown_under_a_mark() { assert!(refused(&cos), "the labelled input stream was not checked"); } +/// Inline image data `7FEI 1 0 0 1 200 0 cm` holds an `EI` the renderer ends the image at, so it +/// runs the `cm` and shows SECRET 200 pt to the right. Redaction reads the image the same way, so +/// a mark drawn over the word as shown takes it out of the file. +#[test] +fn text_after_an_inline_image_is_redacted_where_the_page_shows_it() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page( + "/Font << /F1 4 0 R >>", + "BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", + ); + let id = s.open("inline.pdf", None, Arc::new(page), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + assert!(!page_texts(&s, id).concat().contains("SECRET")); + let saved = s.save_bytes(id).unwrap(); + let held = |d: &[u8]| d.windows(6).any(|w| w == b"SECRET"); + assert!(!held(&saved), "the saved file holds the redacted word"); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!( + !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), + "a saved stream holds the redacted word" + ); +} + /// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer /// draws it, so a mark drawn over it as displayed takes it out of the file. #[test] diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index fca3a1da88..b4186fd4e9 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -74,6 +74,10 @@ pub(crate) struct Output { /// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was /// in force: the operation must fail closed rather than leave that text under the mark. pub vertical: bool, + /// The content holds an inline image the renderer reads no image for + /// ([`pdfcraft_content::Parsed::ambiguous_images`]): what this scope placed after it may not + /// be what the page shows, so a redaction must not trust it. + pub ambiguous: bool, pub tag_changes: Vec<(ObjRef, ObjRef, HashSet, HashSet)>, pub error: Option, } @@ -789,6 +793,7 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); + out.ambiguous = parsed.ambiguous_images > 0; // A rewritten piece loses its comments, so keep where they began and ended: a comment that // ran on into the next piece still hides that piece's first line. let comment_edges = joined.comment_edges(&parsed); @@ -1293,6 +1298,7 @@ fn xobject( scope.fonts = saved_fonts; out.residue += inner.residue; out.vertical |= inner.vertical; + out.ambiguous |= inner.ambiguous; out.doomed.extend_from_slice(&inner.doomed); out.retired_properties.extend(inner.retired_properties); out.tag_changes.extend(inner.tag_changes); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 97d5bcd9fe..e67b373a11 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -411,6 +411,12 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", "", vec![]); + mark(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + assert!(!content(&doc, 0).contains("SECRET")); +} + +/// Without an `EI` the renderer accepts, where the operators resume is a guess: refused. +#[test] +fn a_page_whose_inline_image_the_renderer_cannot_end_is_refused() { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F", "", vec![]); + mark(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1))); +} From 01aa6bcc17b09ad294777d7df49f6b52d6c275fe Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:31:38 +0000 Subject: [PATCH 196/517] docs(redact): describe the overlay, text state, description and inline image rules --- crates/content/README.md | 11 +++++++---- crates/redact/README.md | 22 ++++++++++++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/crates/content/README.md b/crates/content/README.md index 6ab984dd49..fedeadc56b 100644 --- a/crates/content/README.md +++ b/crates/content/README.md @@ -13,7 +13,10 @@ The parser is tolerant (unreadable bytes are skipped and counted, never fatal) a panics. `redact` and (later) `edit` build their interpreters on it; the graphics-state tracker and the `Patch` API of architecture §6 grow here. -Unfiltered inline images with built-in or Indexed colour spaces, and image masks, use their -sample layout (including per-row byte padding) to locate `EI`. This preserves pixel bytes -that look like the terminator. Filtered images and resource-defined colour spaces retain -the tolerant delimiter heuristic; damaged or truncated layouts fall back to it too. +An inline image ends where the renderer's tokenizer (`hayro-syntax`) ends it, so the parser +and the renderer read the same operators after the image, and redaction removes what the page +actually draws. Only where that tokenizer reads no image (its parameters don't read, or no `EI` +passes its checks) does the parser locate `EI` by its own rules: unfiltered images with built-in +or Indexed colour spaces, and image masks, by their sample layout (including per-row byte +padding), other images by the tolerant delimiter heuristic. Such an image is counted in +`Parsed::ambiguous_images`, because the operators after it may not be the ones the page draws. diff --git a/crates/redact/README.md b/crates/redact/README.md index 2caf067b5b..cca3d51fdc 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -20,7 +20,8 @@ clear_marks(&mut doc, None)?; // remove marks without app - **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx). Codecs PdfCraft can't re-encode (DCT, JPX, JBIG2, CCITT) are removed whole (fail-closed); inline images under an - area are removed. Images with separate `/SMask` or `/Mask` data are removed whole; + area are removed. Images with separate `/SMask` or `/Mask` data, and images whose data decodes + to more than twice their declared raster (plus a byte per row and 4 KiB), are removed whole; cleared copies drop `/Alternates` so alternate pixels cannot survive; - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; @@ -31,8 +32,13 @@ clear_marks(&mut doc, None)?; // remove marks without app page as a `/PCMark /Redaction` stream. A verification pass re-reads each redacted page and fails the operation if any glyph or inline -image is still under an area. Content that can't be decoded makes the operation fail rather than -leave it unredacted. A Redact annotation with neither a usable `/QuadPoints` array nor a usable +image is still under an area. It checks every content stream the page had, whatever its +dictionary calls it; only the overlay streams this `apply` added (`Report::overlays`) are skipped. +Text is placed with the font and size set by `Tf` or by an ExtGState's `/Font` through `gs`, and a +form XObject starts from the text state of the content that draws it. Content that can't be +decoded makes the operation fail rather than leave it unredacted, and so does an inline image the +renderer's tokenizer reads no image for (`RedactError::Unreadable`): where the page's operators +resume after it would be a guess. A Redact annotation with neither a usable `/QuadPoints` array nor a usable `/Rect` makes it fail with `RedactError::UnreadableMark` before anything changes, instead of skipping that mark. The count covers the marks on the pages being applied. @@ -50,15 +56,19 @@ as Sanitize drops them, so a full rewrite of a tagged PDF can't keep a removed c A tree where that can't be done (an element written in place, or one too large to walk) makes `apply` fail with `RedactError::Tags` rather than leave the annotation reachable. -Replacement descriptions are removed only from marked-content spans changed by cleanup. -Unchanged spans retain their descriptions; unused property entries are retired during redaction +Replacement descriptions (`/Alt`, `/ActualText`, `/E`) are removed from marked-content spans +changed by cleanup, from every structure element above them (found through both `/P` and `/K`), +and from elements that lose an annotation reference, which also lose `/T`. An element written in +place above one whose descriptions go makes `apply` fail with `RedactError::Tags`. Unchanged +spans retain their descriptions; unused property entries are retired during redaction and hidden-content cleanup, including aliases in shared/inherited resources. Descriptions still used by surviving pages or forms are kept. A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference; a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's `/Resources` is then retired unless another page still inherits it, so it cannot keep a removed -XObject reachable. Pages that aren't redacted are left untouched. +XObject reachable. Surviving form XObjects lose the resource entries for removed XObjects their own +content doesn't draw, for the same reason. Pages that aren't redacted are left untouched. ## Remove hidden information / Sanitize From 8555d6f159a7b2d7d420185642b8901719b457a6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:43:38 +0000 Subject: [PATCH 197/517] fix(parity): cite the renamed renderer-check test and drop fixed redaction gaps --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 362c2aa776..45dda39822 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5709,8 +5709,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::redaction_is_refused_when_the_page_still_shows_text_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"] -notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text whose font is set through an ExtGState or taken by a form from its caller, some predefined CMaps and rotated Type 3 fonts are not removed yet, so those documents cannot be redacted. Removed images and forms are unlinked from page-tree resources (own, inherited or shared), not yet from a form's own resources, annotation appearances or patterns" +evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"] +notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" [[feature]] id = "protect.redact-glyph-removal" From 747fb2dfd888c57d6da2d5d69e103b0c310f7e7b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:26:45 +0000 Subject: [PATCH 198/517] fix(audit): count each word box a line has against the comparison bound covers walks a line's word boxes, so a page with few lines and many words did more work than the line count charged. --- crates/audit/src/lib.rs | 17 +++++++++++++---- crates/audit/src/tests.rs | 7 ++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 4f1877ef67..3ef8f67209 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -112,7 +112,8 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> /// Work and output bounds for one audit. #[derive(Clone, Copy)] struct Limits { - /// Cover-against-line comparisons per page. + /// Cover-against-line comparisons per page, counting a line once per word box it has (at + /// least once), since [`covers`] walks them. comparisons: usize, /// Bytes of covered text across all findings. covered_text: usize, @@ -129,13 +130,21 @@ impl Limits { struct Budget { limits: Limits, comparisons: usize, + /// What comparing one cover with every line of the current page costs. + page_cost: usize, covered_text: usize, } impl Budget { + /// Resets the comparison count for a page with these `lines`. + fn start_page(&mut self, lines: &[TextLine]) { + self.comparisons = 0; + self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(1))); + } + /// The text of the `lines` that `hides`, joined with single spaces. fn hidden_text(&mut self, lines: &[TextLine], hides: impl Fn(&TextLine) -> bool) -> Result { - self.comparisons = self.comparisons.saturating_add(lines.len()); + self.comparisons = self.comparisons.saturating_add(self.page_cost); if self.comparisons > self.limits.comparisons { return Err(AuditError::TooManyComparisons); } @@ -157,11 +166,11 @@ impl Budget { fn audit(doc: &Document, limits: Limits) -> Result, AuditError> { let copying = doc.permissions().is_none_or(|p| p.copy()); - let mut budget = Budget { limits, comparisons: 0, covered_text: 0 }; + let mut budget = Budget { limits, comparisons: 0, page_cost: 0, covered_text: 0 }; let mut out = Vec::new(); for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() { let lines = pdfcraft_edit::text_lines(doc, n).map_err(|e| AuditError::Text(n, e.to_string()))?; - budget.comparisons = 0; + budget.start_page(&lines); // 1. Opaque filled rectangles over extractable text. for r in cover::filled_rects(doc, &page.dict)? { let covered = budget.hidden_text(&lines, |line| is_cover(&r, line))?; diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index de501d01a7..e0ec3888c2 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -659,7 +659,8 @@ fn covered_text_across_findings_is_bounded() { } /// Covers compared against lines are bounded per page, so content that is quadratic in its -/// covers and lines fails the audit instead of running for hours. +/// covers and lines fails the audit instead of running for hours. A line costs one comparison per +/// word: 20 covers against 20 two-word lines are 800. #[test] fn cover_and_line_comparisons_are_bounded() { let mut content = Vec::new(); @@ -670,9 +671,9 @@ fn cover_and_line_comparisons_are_bounded() { content.extend_from_slice(b"1 g 0 0 10 10 re f\n"); } let doc = open(one_page(&content, "")); - let limits = crate::Limits { comparisons: 399, covered_text: usize::MAX }; + let limits = crate::Limits { comparisons: 799, covered_text: usize::MAX }; assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); - let limits = crate::Limits { comparisons: 400, covered_text: usize::MAX }; + let limits = crate::Limits { comparisons: 800, covered_text: usize::MAX }; assert!(crate::audit(&doc, limits).expect("within the budget").is_empty()); } From 3e16c71a51be74adfb1d4343d25b42975671611f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:27:10 +0000 Subject: [PATCH 199/517] fix(audit): count only the covered text a finding reports against the output bound A copy-restricted document reports no text, so it no longer fails the bound on text it withholds. --- crates/audit/src/lib.rs | 24 +++++++++++++++--------- crates/audit/src/tests.rs | 7 +++++++ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 3ef8f67209..509ae977eb 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -115,7 +115,7 @@ struct Limits { /// Cover-against-line comparisons per page, counting a line once per word box it has (at /// least once), since [`covers`] walks them. comparisons: usize, - /// Bytes of covered text across all findings. + /// Bytes of covered text the findings report. covered_text: usize, } @@ -150,18 +150,24 @@ impl Budget { } let mut covered = String::new(); for line in lines.iter().filter(|l| hides(l)) { - let text = line.text.trim(); - self.covered_text = self.covered_text.saturating_add(text.len() + 1); - if self.covered_text > self.limits.covered_text { - return Err(AuditError::TooMuchOutput); - } if !covered.is_empty() { covered.push(' '); } - covered.push_str(text); + covered.push_str(line.text.trim()); } Ok(covered) } + + /// The text a finding reports for `covered`: none when copying is withheld. Charges what it + /// reports against the output bound. + fn report_text(&mut self, covered: String, copying: bool) -> Result { + let reported = if copying { covered } else { String::new() }; + self.covered_text = self.covered_text.saturating_add(reported.len()); + if self.covered_text > self.limits.covered_text { + return Err(AuditError::TooMuchOutput); + } + Ok(reported) + } } fn audit(doc: &Document, limits: Limits) -> Result, AuditError> { @@ -175,7 +181,7 @@ fn audit(doc: &Document, limits: Limits) -> Result, AuditError for r in cover::filled_rects(doc, &page.dict)? { let covered = budget.hidden_text(&lines, |line| is_cover(&r, line))?; if !covered.trim().is_empty() { - let covered_text = if copying { covered } else { String::new() }; + let covered_text = budget.report_text(covered, copying)?; out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text }); } } @@ -190,7 +196,7 @@ fn audit(doc: &Document, limits: Limits) -> Result, AuditError AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect), AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect), }; - let covered_text = if copying { covered } else { String::new() }; + let covered_text = budget.report_text(covered, copying)?; out.push(AuditFinding { page: n, kind, rect, covered_text }); } } diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index e0ec3888c2..659f8de60f 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -697,8 +697,15 @@ fn copy_restricted_documents_withhold_covered_text() { let findings = audit_redactions(&user).expect("audit must run"); assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}"); assert_eq!(findings[0].covered_text, ""); + // The output bound counts the text reported, so withheld text costs nothing. + let nothing = crate::Limits { comparisons: usize::MAX, covered_text: 0 }; + assert_eq!(crate::audit(&user, nothing).expect("withheld text is not output").len(), 1); let owner = Document::open_with_password(bytes, Some("owner")).expect("open"); let findings = audit_redactions(&owner).expect("audit must run"); assert_eq!(findings.len(), 1, "{findings:?}"); assert_eq!(findings[0].covered_text, "SECRET DATA 123"); + let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() }; + assert!(crate::audit(&owner, exact).is_ok()); + let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact }; + assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput))); } From 68c14c9ddca47a5495d2a271a34370b6f1dcc3ab Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:27:10 +0000 Subject: [PATCH 200/517] docs(audit): the comparison bound counts words --- crates/audit/README.md | 2 +- parity/acrobat-features.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/audit/README.md b/crates/audit/README.md index ce2c47915f..cfa2979c21 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -33,7 +33,7 @@ Limits, by design and documented rather than hidden: less than 40% of its words (a sliver of a long value) is not flagged. It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper -than 32 or in a cycle, a walk past the operator bound, more than 50 million cover-against-line +than 32 or in a cycle, a walk past the operator bound, more than 50 million cover-against-word comparisons on a page, or more than 64 MiB of covered text in the report is an error, never "no findings". diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3d209c87f3..6a2d0ccf98 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5842,7 +5842,7 @@ status = "partial" commands = [] tools = ["doc_audit_redactions"] evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "crates/automation/tests/it/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/it/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/it/audit.rs::audit_rectangles_match_the_tool_in_top_left_points", "crates/audit/src/tests.rs::rectangle_filled_after_restore_is_a_cover", "crates/audit/src/tests.rs::later_text_on_the_line_does_not_clear_an_earlier_cover", "crates/audit/src/tests.rs::indirect_subtype_is_resolved", "crates/audit/src/tests.rs::copy_restricted_documents_withhold_covered_text", "crates/audit/src/tests.rs::cover_and_line_comparisons_are_bounded", "crates/audit/src/tests.rs::covered_text_across_findings_is_bounded"] -notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the start of the text line it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content, or more than 50 million cover-against-line comparisons on a page or 64 MiB of covered text in the report, is an error, never a clean bill. A document that withholds copying gets every finding with the covered text left out. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." +notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the start of the text line it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content, or more than 50 million cover-against-word comparisons on a page or 64 MiB of covered text in the report, is an error, never a clean bill. A document that withholds copying gets every finding with the covered text left out. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." [[feature]] id = "protect.remove-hidden-on-close" From 39844f06b58bc96bae72d3fb56faaa3b6201c945 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 15:39:48 -0400 Subject: [PATCH 201/517] Release 0.12.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 72 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 68 +++++++++++++++++++++++++-------------------------- 2 files changed, 70 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e318b7a77c..6518430017 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3936,7 +3936,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "craft-quit", @@ -3962,7 +3962,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3974,7 +3974,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3985,7 +3985,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3996,7 +3996,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "flate2", @@ -4018,7 +4018,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4036,11 +4036,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.11.0" +version = "0.12.0" [[package]] name = "pdfcraft-content" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4048,7 +4048,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "hayro-syntax", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.11.0" +version = "0.12.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.11.0" +version = "0.12.0" dependencies = [ "aes", "md-5", @@ -4091,7 +4091,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4106,7 +4106,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "craft-docpath", @@ -4147,14 +4147,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.11.0" +version = "0.12.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.11.0" +version = "0.12.0" dependencies = [ "flate2", "proptest", @@ -4164,7 +4164,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.11.0" +version = "0.12.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4174,7 +4174,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4187,11 +4187,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.11.0" +version = "0.12.0" [[package]] name = "pdfcraft-js" -version = "0.11.0" +version = "0.12.0" dependencies = [ "boa_engine", "chrono", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4212,14 +4212,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.11.0" +version = "0.12.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4229,7 +4229,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "pdfcraft-annot", @@ -4242,11 +4242,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.11.0" +version = "0.12.0" [[package]] name = "pdfcraft-organize" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4257,7 +4257,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-channel", "futures-lite", @@ -4267,7 +4267,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.11.0" +version = "0.12.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4278,7 +4278,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.11.0" +version = "0.12.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4306,7 +4306,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.11.0" +version = "0.12.0" dependencies = [ "bytemuck", "flate2", @@ -4325,7 +4325,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.11.0" +version = "0.12.0" dependencies = [ "aes", "aws-lc-rs", @@ -4360,11 +4360,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.11.0" +version = "0.12.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.11.0" +version = "0.12.0" dependencies = [ "arboard", "base64 0.22.1", @@ -4409,7 +4409,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.11.0" +version = "0.12.0" dependencies = [ "eframe", "egui", @@ -4421,7 +4421,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "image", @@ -4435,7 +4435,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index 2012403144..5841b50c68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.11.0" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1. # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.11.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.11.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.11.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.11.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.11.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.11.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.11.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.11.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.11.0" } -pdfcraft-model = { path = "crates/model", version = "0.11.0" } -pdfcraft-content = { path = "crates/content", version = "0.11.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.11.0" } -pdfcraft-print = { path = "crates/print", version = "0.11.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.11.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.11.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.11.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.11.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.11.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.11.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.11.0" } -pdfcraft-js = { path = "crates/js", version = "0.11.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.11.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.11.0" } -pdfcraft-export = { path = "crates/export", version = "0.11.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.11.0" } -pdfcraft-create = { path = "crates/create", version = "0.11.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.11.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.11.0" } -pdfcraft-render = { path = "crates/render", version = "0.11.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.11.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.11.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.11.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.11.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.12.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.12.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.12.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.12.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.12.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.12.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.12.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.12.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.12.0" } +pdfcraft-model = { path = "crates/model", version = "0.12.0" } +pdfcraft-content = { path = "crates/content", version = "0.12.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.12.0" } +pdfcraft-print = { path = "crates/print", version = "0.12.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.12.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.12.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.12.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.12.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.12.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.12.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.12.0" } +pdfcraft-js = { path = "crates/js", version = "0.12.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.12.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.12.0" } +pdfcraft-export = { path = "crates/export", version = "0.12.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.12.0" } +pdfcraft-create = { path = "crates/create", version = "0.12.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.12.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.12.0" } +pdfcraft-render = { path = "crates/render", version = "0.12.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.12.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.12.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.12.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.12.0", default-features = false } # pdf hayro = "0.7.1" From 3dbdc13b1d8b2eaeca3b26619ef16790945f5df3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:46:11 +0000 Subject: [PATCH 202/517] Image resolution walk charges each form entry to the page's form allowance --- crates/optimize/src/images.rs | 4 ++++ crates/optimize/tests/optimize.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 0eb882ec2c..896542285d 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -127,6 +127,10 @@ impl Walk<'_> { } self.rewalks += 1; } + if !self.budget.enter_form() { + self.complete = false; + return; + } let Ok(data) = self.budget.decode(s) else { self.complete = false; return; diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index fd1ef3cd94..6cf53d4fc9 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1023,6 +1023,19 @@ fn images_are_not_resampled_when_some_uses_could_not_be_measured() { assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); } +/// Each form entry counts against the page's form allowance, even for a form that decodes to +/// nothing and so costs no decoded bytes. +#[test] +fn images_are_not_resampled_when_a_page_enters_more_forms_than_allowed() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, false, None); + let empty = form(&mut doc, None, ""); + let draws = "/E Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1); + page(&mut doc, &[("E", empty), ("Im", img)], &format!("q 72 0 0 72 36 36 cm /Im Do Q {draws}")); + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); +} + #[test] fn image_resolution_keeps_comments_across_content_pieces() { for ending in ["", "\n", "\r", "\r\n"] { From e0e7d4462eb127f850b01b0221227634295afb77 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:46:50 +0000 Subject: [PATCH 203/517] Cover audit charges each form entry to the page's form allowance --- crates/audit/src/cover.rs | 10 +++++++--- crates/audit/src/lib.rs | 2 ++ crates/audit/src/tests.rs | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 516bb193d2..93394ccfb0 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -109,7 +109,8 @@ struct Walker<'a> { pieces: &'a Pieces, out: Vec, ops_seen: usize, - /// Decoded bytes left for the page, its forms included: spending it fails the audit. + /// Decoded bytes and form entries left for the page, its forms included: spending either + /// fails the audit. budget: DecodeBudget, } @@ -273,6 +274,9 @@ impl<'a> Walker<'a> { }) .unwrap_or(Matrix::IDENTITY); let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + if !self.budget.enter_form() { + return Err(AuditError::TooManyForms); + } // A form stream that cannot be decoded fails the audit: it might hide a cover. let data = self.budget.decode_strict(stream)?; let ops = parse(&data).ops; @@ -306,8 +310,8 @@ fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { /// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint /// positions. A page's content streams are walked once, joined, with one graphics state, so /// state set in an earlier stream is still in force in the later ones. Any content stream that -/// cannot be decoded, or content and forms that decode past one page's [`DecodeBudget`] in all, -/// is an audit failure, never an empty result. +/// cannot be decoded, or content and forms that decode or enter past one page's [`DecodeBudget`] +/// in all, is an audit failure, never an empty result. pub fn filled_rects(doc: &Document, page: &Dict) -> Result, AuditError> { let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let contents: Vec = match page.get(b"Contents") { diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 365889d61a..8277cb0aa6 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -77,6 +77,8 @@ pub enum AuditError { TooManyOps, #[error("form XObjects nest deeper than the audit follows")] TooDeep, + #[error("content walk entered more form XObjects than the audit follows")] + TooManyForms, #[error("page has a malformed /Annots entry")] BadAnnots, #[error(transparent)] diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 8155c6dece..3d0c80e0b9 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -611,3 +611,17 @@ fn repeated_stream_references_share_one_decode_budget() { let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); assert!(crate::cover::filled_rects(&doc, &page).is_err(), "repeated form draws"); } + +#[test] +fn form_entries_past_the_page_allowance_fail_the_audit() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + // An empty form decodes to no bytes, so only the count of entries bounds drawing it again. + let mut doc = Document::new_empty(); + let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::from_raw(form_dict, Vec::new())); + let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1).into_bytes())); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); + assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms))); +} From e2a67716419d7a6c186ce45e48542dbf444288f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:48:33 +0000 Subject: [PATCH 204/517] Redaction charges a page's content streams and its forms to one decode budget --- crates/redact/src/interp.rs | 11 +++++++---- crates/redact/src/lib.rs | 22 ++++++++++++++++----- crates/redact/src/sanitize.rs | 7 ++++--- crates/redact/src/tests.rs | 36 +++++++++++++++++++++++++++++++++-- 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 0a3b730f60..9851c2e8c0 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -106,13 +106,16 @@ pub(crate) struct Scope<'a> { fonts: HashMap, Rc>, used_names: Vec>, depth: usize, - /// Form entries and decoded form bytes left for this page: shared forms drawn from other - /// shared forms would otherwise multiply the work with every level. + /// Form entries and decoded bytes left for this page, its content streams already charged: + /// shared forms drawn from other shared forms would otherwise multiply the work with every + /// level. budget: DecodeBudget, } impl<'a> Scope<'a> { - pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report) -> Self { + /// A scope whose form walk spends `budget`, what is left of the page's allowance after its + /// content streams were decoded. + pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget) -> Self { Scope { rects, mode, @@ -127,7 +130,7 @@ impl<'a> Scope<'a> { fonts: HashMap::new(), used_names: Vec::new(), depth: 0, - budget: DecodeBudget::page(), + budget, } } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index f847977201..fce9f432a9 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -230,6 +230,17 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec, Vec) { /// A page's content streams: (the references or inline objects as listed, their decoded data), /// decoded within one page's [`pdfcraft_cos::DecodeBudget`]. pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec, Vec>), RedactError> { + page_streams_within(doc, page, index, &mut pdfcraft_cos::DecodeBudget::page()) +} + +/// [`page_streams`], decoded against `budget`, which the walk of the page's forms then goes on +/// spending. +pub(crate) fn page_streams_within( + doc: &Document, + page: &Dict, + index: usize, + budget: &mut pdfcraft_cos::DecodeBudget, +) -> Result<(Vec, Vec>), RedactError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -238,7 +249,6 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result< }, }; let mut data = Vec::new(); - let mut budget = pdfcraft_cos::DecodeBudget::page(); for o in &list { match &*doc.resolve(o) { // Strict: a tolerant decode keeps what precedes corruption and drops the rest, which @@ -396,10 +406,11 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); // 1. Content. - let (list, data) = page_streams(doc, &page.dict, pi)?; + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let out = { - let mut scope = Scope::new(&rects, Mode::Apply, &mut report); + let mut scope = Scope::new(&rects, Mode::Apply, &mut report, budget); process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) }; // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot @@ -601,13 +612,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); - let (list, data) = page_streams(doc, &page.dict, pi)?; + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; // The overlay stream draws text of its own: the overlay label is allowed. let original: Vec> = list.iter().zip(data).filter(|(o, _)| !o.as_ref().is_some_and(|r| report.overlays.contains(&r))).map(|(_, d)| d).collect(); let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut scratch = Report::default(); - let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch); + let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch, budget); residue += process(doc, &mut scope, &original, &resources, pdfcraft_content::Matrix::IDENTITY).residue; } if residue > 0 { diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 5b2918f01c..0b6c04fdf0 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -8,7 +8,7 @@ use pdfcraft_content::Matrix; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::interp::{Mode, Scope, process}; -use crate::{RedactError, Report, annots_of, listed_annotations, page_streams}; +use crate::{RedactError, Report, annots_of, listed_annotations, page_streams, page_streams_within}; /// The categories of Acrobat's Remove Hidden Information panel that PdfCraft handles. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -356,7 +356,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let n = pdfcraft_model::pages(doc).len(); for pi in 0..n { let page = pdfcraft_model::pages(doc).swap_remove(pi); - let Ok((list, data)) = page_streams(doc, &page.dict, pi) else { + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let Ok((list, data)) = page_streams_within(doc, &page.dict, pi, &mut budget) else { if write { return Err(RedactError::Unreadable(pi + 1)); } @@ -368,7 +369,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut report = Report::default(); let mode = if write { Mode::Apply } else { Mode::Verify }; - let mut scope = Scope::new(&[], mode, &mut report); + let mut scope = Scope::new(&[], mode, &mut report, budget); if text { let c = page.crop(doc); scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 550903e494..2478e0b31b 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -48,10 +48,11 @@ fn content(doc: &Document, page: usize) -> String { /// Glyphs (and inline images) of page `page` under `rects` (the verifier's count). fn under(doc: &mut Document, page: usize, rects: &[[f64; 4]]) -> usize { let p = pdfcraft_model::pages(doc).swap_remove(page); - let (_, data) = page_streams(doc, &p.dict, page).unwrap(); + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (_, data) = page_streams_within(doc, &p.dict, page, &mut budget).unwrap(); let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut rep = Report::default(); - let mut scope = Scope::new(rects, Mode::Verify, &mut rep); + let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget); process(doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY).residue } @@ -2713,3 +2714,34 @@ fn repeated_name_tree_kids_count_towards_the_node_budget() { let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); assert_eq!(counts[&Hidden::Attachments], 1, "the walk went past its node budget to the last kid"); } + +/// Content streams and the forms they draw spend one page allowance: 130 MiB of content and a +/// 130 MiB form are each under it, together past it. +#[test] +fn content_and_forms_share_one_budget_per_page() { + use pdfcraft_cos::{Dict, Stream}; + let mut doc = one_page(b"", "", vec![]); + let mut content = b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET /X Do\n%".to_vec(); + content.resize(130 << 20, b'x'); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), &content))); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + let mut body = b"%".to_vec(); + body.resize(130 << 20, b'x'); + let form = doc.add(Object::Stream(Stream::flate(form, &body))); + doc.update_dict(ObjRef::new(3, 0), |d| { + let mut xobjects = Dict::new(); + xobjects.set(b"X".to_vec(), Object::Ref(form)); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(ObjRef::new(5, 0))); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + d.set(b"Contents".to_vec(), Object::Ref(content)); + }) + .unwrap(); + mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], ""); + assert!(apply(&mut doc, None).is_err(), "the form past the page's allowance was read"); +} From f980b34ef3c5bc866809dba24f76a4a7f2b153a8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:49:40 +0000 Subject: [PATCH 205/517] Text and image reading charge a page's content and its forms to one decode budget --- crates/edit/src/images.rs | 5 +++-- crates/edit/src/tests.rs | 18 +++++++++++++++++ crates/edit/src/text.rs | 41 +++++++++++++++++++++++---------------- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 1f7773a52c..6f14271be4 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -377,8 +377,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() }; - let pieces = joined(&content_streams(doc, &p.dict)?); + let mut decode = pdfcraft_cos::DecodeBudget::page(); + let pieces = joined(&crate::text::content_streams_within(doc, &p.dict, &mut decode)?); + let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::new(decode) }; walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 61e5e958ed..1395fb1c35 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6430,3 +6430,21 @@ fn reading_images_stops_at_the_page_record_limit() { assert!(images.len() <= crate::images::MAX_PAGE_IMAGES, "{} images", images.len()); } } + +#[test] +fn reading_spends_one_decode_budget_on_content_and_forms() { + // 130 MiB of content and a 130 MiB form (a kilobyte each on disk): each fits a page's budget, + // together they don't, so reading leaves the form out and says so. + let doc = page_with(|doc| { + let mut body = b"%".to_vec(); + body.resize(130 << 20, b'x'); + let form: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::flate(form, &body)); + let mut content = b"/X Do\n%".to_vec(); + content.resize(130 << 20, b'x'); + let content = doc.add(Stream::flate(Dict::new(), &content)); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + (Object::Ref(content), [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect()) + }); + assert!(crate::text::reading_blocks_checked(&doc, 0).unwrap().truncated); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 34768527b3..1f35bcd472 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -621,6 +621,12 @@ struct Ts { /// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading /// or rewriting a page from part of its content would lose the rest. pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result)>, EditError> { + content_streams_within(doc, page, &mut DecodeBudget::page()) +} + +/// [`content_streams`], decoded against `budget`, which a walk of the page's forms then goes on +/// spending. +pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Result)>, EditError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -628,7 +634,6 @@ pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result vec![c.clone()], }, }; - let mut budget = DecodeBudget::page(); let mut out = Vec::with_capacity(list.len()); for o in list { let data = match &*doc.resolve(&o) { @@ -1500,24 +1505,25 @@ pub const MAX_FORM_DEPTH: usize = 50; pub const MAX_FORM_VISITS: usize = 50_000; /// One page's walk through the form XObjects it draws: how many it entered (the page's -/// [`MAX_FORM_VISITS`]), the bytes their content may decode to in all, and whether a limit left -/// a form unread. +/// [`MAX_FORM_VISITS`]), the bytes the page's content and forms may still decode to, and whether +/// a limit left a form unread. pub(crate) struct FormBudget { visits: usize, decode: DecodeBudget, - /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past - /// [`MAX_FORM_VISITS`]: what it draws is missing from the walk. + /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`], comes past + /// [`MAX_FORM_VISITS`] or past the decode budget: what it draws is missing from the walk. pub(crate) truncated: bool, } +impl FormBudget { + /// The walk of a page's forms, spending what `decode` has left after the page's content. + pub(crate) fn new(decode: DecodeBudget) -> Self { + Self { visits: 0, decode, truncated: false } + } +} + /// A form XObject a page draws: its object, content, resources and its matrix composed with the /// CTM at the `Do`. -impl Default for FormBudget { - fn default() -> Self { - Self { visits: 0, decode: DecodeBudget::page(), truncated: false } - } -} - pub(crate) struct FormCall { pub(crate) obj: Option, pub(crate) data: Vec, @@ -1632,8 +1638,9 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E } let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict)?; - let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() }; + let mut decode = DecodeBudget::page(); + let streams = content_streams_within(doc, &p.dict, &mut decode)?; + let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::new(decode) }; let mut carry = Carry::new(); // Read the page's streams as the one stream they are, in one pass, as rendering and editing // do: a comment, an operator's operands or a marked-content sequence (its /ActualText too) @@ -2506,10 +2513,10 @@ pub fn reading_blocks(doc: &Document, page: usize) -> Result, Edi pub struct ReadingBlocks { /// The paragraphs, as [`reading_blocks`] returns them. pub blocks: Vec, - /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], or more of them than - /// [`MAX_FORM_VISITS`]. Reading leaves those out to stay bounded on hostile files, so their - /// text is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses - /// their images too. + /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], more of them than + /// [`MAX_FORM_VISITS`], or more content and forms than one page's decode budget. Reading + /// leaves those out to stay bounded on hostile files, so their text is missing from + /// `blocks`; [`crate::reading_images`] walks the same forms and misses their images too. pub truncated: bool, } From 089882f372ce0c6517be24ada8c80fe236ff14d3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:50:42 +0000 Subject: [PATCH 206/517] Marking a figure decorative refuses content past the page's decode budget --- crates/a11y/src/alt.rs | 13 ++++++++++++- crates/a11y/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 4d2dab646e..f22a25f733 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -23,6 +23,8 @@ pub enum AltError { Untagged, #[error("object {0} is not a figure")] NotAFigure(u32), + #[error("the page has more content than can be edited")] + TooLarge, #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } @@ -375,7 +377,16 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: let mut budget = DecodeBudget::page(); for entry in &entries { match &*doc.resolve(entry) { - Object::Stream(s) => streams.push(Some((s.clone(), budget.decode(s).unwrap_or_default()))), + Object::Stream(s) => { + let data = match budget.decode(s) { + Ok(data) => data, + // Content past the budget may hold more of the figure: editing the rest + // would leave that part marked as a figure the tree no longer has. + Err(_) if budget.exhausted() => return Err(AltError::TooLarge), + Err(_) => Vec::new(), + }; + streams.push(Some((s.clone(), data))); + } _ => streams.push(None), } } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 1c79caf3e7..7db743536a 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1262,3 +1262,19 @@ fn repeated_content_references_share_one_decode_budget() { let data = crate::alt::page_content(&doc, &page); assert!(data.len() <= MAX_PAGE_DECODED + 300, "{} bytes", data.len()); } + +/// A figure whose marked content goes on in a stream past the page's decode budget is refused, +/// not removed with that content still pointing at it. +#[test] +fn marking_decorative_refuses_content_past_the_decode_budget() { + use pdfcraft_cos::{Dict, Object, ObjRef, Stream}; + let mut doc = figures_doc(); + let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let last = doc.add(Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >> BDC 0 0 5 5 re f EMC".to_vec())); + let mut contents = vec![Object::Ref(ObjRef::new(4, 0))]; + contents.extend(std::iter::repeat_n(Object::Ref(filler), 256)); + contents.push(Object::Ref(last)); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap(); + assert!(mark_decorative(&mut doc, ObjRef::new(7, 0)).is_err()); + assert_eq!(figures(&doc).len(), 2, "the figure stays"); +} From 9d5bcc074590f69585f232e485808cc7ba4053b6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:51:47 +0000 Subject: [PATCH 207/517] Accessibility check asks for a manual check where page content ran past the decode budget --- crates/a11y/src/content.rs | 21 +++++++++++++++++++-- crates/a11y/src/lib.rs | 12 ++++++++++++ crates/a11y/src/tests.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index 66f83867e3..fd5f0d305f 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -20,6 +20,8 @@ pub(crate) struct Scan { pub untagged: BTreeMap, /// Page → fonts used. pub fonts: BTreeMap>, + /// Pages read only in part: content or forms past the page's decode budget are missing. + pub incomplete: BTreeSet, } const MAX_FORM_DEPTH: usize = 12; @@ -32,6 +34,9 @@ impl Scan { let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut budget = DecodeBudget::page(); let data = crate::alt::page_content_within(doc, &page.dict, &mut budget); + if budget.remaining() == 0 { + s.incomplete.insert(p); + } let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new(), budget }; w.content(&data, &res, false, 0); } @@ -100,7 +105,20 @@ impl Walk<'_> { self.scan.has_images = true; untagged += usize::from(!inside); } - Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) && self.budget.enter_form() => { + Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) => { + if !self.budget.enter_form() { + self.scan.incomplete.insert(self.page); + continue; + } + let data = match self.budget.decode(x) { + Ok(data) => data, + Err(_) => { + if self.budget.exhausted() { + self.scan.incomplete.insert(self.page); + } + continue; + } + }; self.forms.insert(r); let inner = x .dict @@ -108,7 +126,6 @@ impl Walk<'_> { .map(|o| self.doc.resolve(o)) .and_then(|o| o.as_dict().cloned()) .unwrap_or_else(|| res.clone()); - let data = self.budget.decode(x).unwrap_or_default(); self.content(&data, &inner, inside, depth + 1); self.forms.remove(&r); } diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 21d8ea5820..280f4e845f 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -681,6 +681,18 @@ pub fn check(doc: &Document, options: &Options) -> Report { } } } + // A page read only in part may hold what the scan didn't find: text that makes the + // document more than images, untagged content, or a font without Unicode mappings. + let unsure = !scan.incomplete.is_empty() + && match rule { + Rule::ImageOnly => !scan.has_text, + Rule::TaggedContent | Rule::CharacterEncoding => findings.is_empty(), + _ => false, + }; + if unsure { + results.push(RuleResult { rule, status: Status::Manual, findings: Vec::new() }); + continue; + } if total > MAX_FINDINGS { let more = total - MAX_FINDINGS; findings.truncate(MAX_FINDINGS); diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 7db743536a..ed3a5146e2 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1278,3 +1278,31 @@ fn marking_decorative_refuses_content_past_the_decode_budget() { assert!(mark_decorative(&mut doc, ObjRef::new(7, 0)).is_err()); assert_eq!(figures(&doc).len(), 2, "the figure stays"); } + +/// Text past the page's decode budget is unread, so a page that shows images and no text so far +/// can't be called image-only, nor its content all tagged. +#[test] +fn content_past_the_decode_budget_needs_a_manual_check() { + use pdfcraft_cos::{Dict, Object, ObjRef, Stream}; + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>".into(), + stream("/Artifact BMC q 200 0 0 200 0 0 cm /Im Do Q EMC"), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream" + .into(), + FONT.into(), + ], + "", + ); + let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let text = doc.add(Stream::from_raw(Dict::new(), b"BT /F1 12 Tf 10 10 Td (Hello) Tj ET".to_vec())); + let mut contents = vec![Object::Ref(ObjRef::new(4, 0))]; + contents.extend(std::iter::repeat_n(Object::Ref(filler), 256)); + contents.push(Object::Ref(text)); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap(); + let report = check(&doc, &Options::default()); + assert_eq!(status(&report, Rule::ImageOnly), Status::Manual); + assert_eq!(status(&report, Rule::TaggedContent), Status::Manual); +} From f02b998122731132cbf7c119a7f41630160908d6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:54:10 +0000 Subject: [PATCH 208/517] Format the accessibility tests --- crates/a11y/src/tests.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index ed3a5146e2..9332d29dfa 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1267,7 +1267,7 @@ fn repeated_content_references_share_one_decode_budget() { /// not removed with that content still pointing at it. #[test] fn marking_decorative_refuses_content_past_the_decode_budget() { - use pdfcraft_cos::{Dict, Object, ObjRef, Stream}; + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; let mut doc = figures_doc(); let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); let last = doc.add(Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >> BDC 0 0 5 5 re f EMC".to_vec())); @@ -1283,12 +1283,13 @@ fn marking_decorative_refuses_content_past_the_decode_budget() { /// can't be called image-only, nor its content all tagged. #[test] fn content_past_the_decode_budget_needs_a_manual_check() { - use pdfcraft_cos::{Dict, Object, ObjRef, Stream}; + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; let mut doc = pdf( &[ "<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>" + .into(), stream("/Artifact BMC q 200 0 0 200 0 0 cm /Im Do Q EMC"), "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream" .into(), From c829a1ad44cb4128d68b71895238ae1e8941219d Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:03:37 -0300 Subject: [PATCH 209/517] fix(sign): a removed annotation is not hidden behind an added signature Review follow-up: an indirect /Annots array that drops a signed annotation while gaining a signature widget was judged by the widget alone, so a form-fill certification let a signed comment be deleted. A removed comment is now a comment change, and a removed widget is a change no permission covers. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/sign/src/pdf.rs | 17 ++++++++++++++--- crates/sign/tests/it/pdf.rs | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index cd555a9893..0cd58d7f95 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1338,12 +1338,23 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod (Some(k), _) => k, // The Document Security Store's own arrays and dictionaries, when they only grew. (_, _) if dss.contains(&num) => kind::SECURITY_STORE, - // A signed page's indirect /Annots array, still an array: what it gained. + // A signed page's indirect /Annots array, still an array: what it lost, then what it + // gained. Dropping a signed annotation is never hidden behind an added signature. (_, Object::Array(now)) if signed_annots.contains(&r) && before.as_deref().is_some_and(|b| b.as_array().is_some()) => { let was: HashSet = before.as_deref().and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_ref).collect()).unwrap_or_default(); - let added: Vec = now.iter().filter_map(Object::as_ref).filter(|r| !was.contains(r)).collect(); - added_annots(doc, &added) + let kept: HashSet = now.iter().filter_map(Object::as_ref).collect(); + let removed: Vec = was.iter().filter(|r| !kept.contains(r)).copied().collect(); + if removed.is_empty() { + let added: Vec = now.iter().filter_map(Object::as_ref).filter(|r| !was.contains(r)).collect(); + added_annots(doc, &added) + } else if removed.iter().any(|r| old.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))) { + // A form field or signature widget taken off the page: no permission covers it. + kind::OTHER + } else { + // Deleting comments is a comment change (permitted at P 3 only). + kind::COMMENTS + } } (_, Object::Dict(d)) => { let was = before.as_deref().and_then(Object::as_dict); diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index cd0b713a8c..b3a025fc2a 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -178,6 +178,30 @@ fn an_indirect_annots_array_is_judged_by_the_annotations_it_gains() { let certified = pdfcraft_sign::sign(&open(&unsigned), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap(); let s = check(&edit_after(&certified, comment)); assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::COMMENTS]))); + // Under that certification, a signed comment swapped for a new signature widget is still a + // deleted comment, and a form field taken off the page is permitted by no level. + let commented = pdfcraft_sign::sign(&open(&edit_after(&unsigned, comment)), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap(); + let swap = |drop: &dyn Fn(&Document, &Object) -> bool| { + edit_after(&commented, |doc| { + let mut w = pdfcraft_cos::Dict::new(); + w.set(b"Type".to_vec(), Object::name("Annot")); + w.set(b"Subtype".to_vec(), Object::name("Widget")); + w.set(b"FT".to_vec(), Object::name("Sig")); + w.set(b"T".to_vec(), PdfString::text("Later")); + w.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0); 4])); + let w = doc.add(Object::Dict(w)); + let mut a = doc.get(annots).as_array().cloned().unwrap(); + a.retain(|o| !drop(doc, o)); + a.push(Object::Ref(w)); + doc.set(annots, Object::Array(a)); + }) + }; + let subtype = + |doc: &Document, o: &Object, t: &[u8]| o.as_ref().is_some_and(|r| doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(t))); + let s = check(&swap(&|doc, o| subtype(doc, o, b"Text"))); + assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::COMMENTS]))); + let s = check(&swap(&|doc, o| subtype(doc, o, b"Widget") && o.as_ref().is_some_and(|r| r.num == 6))); + assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::OTHER]))); } /// Issue #1328: a signature field whose /V is null, directly or through a reference, is unsigned: From 02f0f7396f6b823bc7736fcc0a909b382aed2651 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:04:04 +0000 Subject: [PATCH 210/517] fix(annot): count web-address text after its UTF-8 conversion An address of invalid UTF-8 grows up to three times when converted, so the listing's 16 MiB cap counted fewer bytes than it kept. The cap now counts the converted text; the raw length is still checked first, so an address already over the cap is never converted. --- crates/annot/src/links.rs | 12 ++++++++---- crates/annot/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index b6a2c7575c..6574881377 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -78,7 +78,8 @@ pub struct LinkItem { pub style: LinkStyle, } -/// Web-address text one listing keeps; past it a link lists as [`LinkAction::Other`]. +/// Bytes of web-address text one listing keeps, counted after conversion to UTF-8; past it a link +/// lists as [`LinkAction::Other`]. pub(crate) const MAX_URI_BYTES: usize = 16 << 20; fn nums(doc: &Document, o: Option<&Object>) -> Vec { @@ -103,10 +104,13 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) Some(b"URI") => { let uri = a.get(b"URI").map(|u| doc.resolve(u)); let bytes = uri.as_ref().and_then(|u| u.as_string()).map_or(&[][..], |s| &s.bytes[..]); - match uri_budget.checked_sub(bytes.len()) { - Some(left) => { + // The raw length first, so an address already over the budget is never converted; then the + // converted text, which is up to three times longer where the bytes are not UTF-8. + let text = (bytes.len() <= *uri_budget).then(|| String::from_utf8_lossy(bytes)); + match text.and_then(|t| Some((uri_budget.checked_sub(t.len())?, t))) { + Some((left, text)) => { *uri_budget = left; - LinkAction::Uri(String::from_utf8_lossy(bytes).into_owned()) + LinkAction::Uri(text.into_owned()) } None => LinkAction::Other("web address too long".into()), } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index c52732ea85..6eb94b8304 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1541,6 +1541,25 @@ fn link_listing_skips_repeated_refs_and_caps_web_address_text() { assert!(matches!(&all[0].action, links::LinkAction::Uri(u) if u.len() == 64 << 10)); } +#[test] +fn web_address_cap_counts_the_text_kept_after_conversion() { + // 200 links sharing a 64 KiB address of invalid UTF-8: 12.5 MiB of bytes, each byte kept as a + // three-byte replacement character. + let annots = "<< /Subtype /Link /Rect [0 0 1 1] /A 4 0 R >>".repeat(200); + let doc = doc_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + "<< /S /URI /URI 5 0 R >>".into(), + format!("<{}>", "FF".repeat(64 << 10)), + ]); + let all = links::list(&doc); + assert_eq!(all.len(), 200); + let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum(); + assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept"); + assert!(kept > 0); +} + #[test] fn cloud_outline_has_a_bump_budget() { // 50 long edges would each ask for 10,000 bumps of radius 1. From 071130d49868409f68066c55b74c0d7366e90699 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:05:05 +0000 Subject: [PATCH 211/517] fix(render): list links and attachments after the comment budget fills Reaching the 64 MiB comment budget ended the whole annotation scan, so links and file attachments listed after it were missing, and a later JavaScript link left the document unmarked. Past the budget, comments are no longer built, and the scan goes on for links and attachments. --- crates/render/src/inspect.rs | 37 ++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 6c3bd2c0de..91be4de372 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -1166,6 +1166,9 @@ impl<'a> Inspector<'a> { // first of them (§12.5.2 allows one page per annotation). let mut seen = HashSet::new(); let mut kept = 0usize; + // Once the comment budget is spent, comments are no longer built, but links and attachments + // are still listed. + let mut comments_full = false; for (id, page) in pages { let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; if let Ok(Object::Reference(r)) = page_dict.get(b"Annots") @@ -1204,6 +1207,9 @@ impl<'a> Inspector<'a> { { continue; } + if comments_full { + continue; + } let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); let mut color = match d.get(b"C").map(|o| self.resolve(o)) { Ok(Object::Array(c)) if c.len() == 3 => { @@ -1265,13 +1271,11 @@ impl<'a> Inspector<'a> { }; kept = kept.saturating_add(retained(&annotation)); if kept > MAX_ANNOTATION_BYTES { - break; + comments_full = true; + continue; } info.annotations.push(annotation); } - if kept > MAX_ANNOTATION_BYTES { - break; - } } info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); } @@ -2652,6 +2656,31 @@ trailer << /Root 1 0 R >> assert!(notes.iter().map(retained).sum::() <= MAX_ANNOTATION_BYTES); } + /// Links and attachments after the comment budget fills are still listed, and a JavaScript link + /// among them still marks the document. + #[test] + fn links_and_attachments_past_the_comment_budget_are_listed() { + let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 6 0 R >> ".repeat(1100); + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{small} \ + << /Subtype /Link /Rect [0 0 1 1] /A << /S /JavaScript /JS (1) >> >>] >> endobj\n\ + 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\ + << /Subtype /FileAttachment /Rect [0 0 1 1] /FS << /Type /Filespec /F (a.txt) >> >>] >> endobj\n\ + 6 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + "x".repeat(64 << 10), + ); + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; + Inspector::new(&doc).fill(&mut info); + assert!(info.annotations.len() < 1100, "the comment budget filled"); + assert_eq!(info.links.len(), 1); + assert!(info.has_javascript); + assert_eq!(info.attachments.len(), 1); + } + /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. From a887405ea15a459c7f5867c025b26d3afe9f9429 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:07:41 +0000 Subject: [PATCH 212/517] fix(sign): don't report valid when a DSS cap hides revocation evidence The evidence caps counted repeated references to one stream, so a /DSS array could list one benign stream 1024 times ahead of a revoking CRL, and the check never reached it. Repeats are skipped before the entry cap counts anything, and when a cap still stops an array being read in full without a revocation found, the signature's status is unknown rather than valid, with the reason in its details. --- crates/sign/src/pdf.rs | 115 +++++++++++++++++++------ crates/sign/src/pdf_discovery_tests.rs | 12 ++- crates/sign/tests/it/pdf.rs | 31 +++++++ 3 files changed, 126 insertions(+), 32 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 04598e0333..f4fe2c601e 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -892,7 +892,8 @@ fn finish_validation( // Revocation evidence embedded in the document security store, if any. A verified // revocation is final: later blocks must not soften the verdict. let signer = info.chain.first().cloned(); - let revoked = check_dss_revocation(doc, signer.as_ref(), &at, info); + let revocation = check_dss_revocation(doc, signer.as_ref(), &at, info); + let revoked = revocation == DssRevocation::Revoked; // Changes after signing. info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) { Modification::None @@ -929,6 +930,12 @@ fn finish_validation( problems = true; } } + if revocation == DssRevocation::Incomplete { + if !problems { + info.status = Status::Unknown; + } + problems = true; + } if let Some(t) = info.timestamp_time.or(info.signing_time) && !cert.valid_at(t) { @@ -975,46 +982,86 @@ fn finish_validation( } } -/// Evidence streams read from one `/DSS` array. +/// Distinct evidence streams read from one `/DSS` array. const MAX_DSS_ENTRIES: usize = 1024; /// Decoded bytes read from one `/DSS` array; real CRLs and OCSP responses are kilobytes each. const MAX_DSS_BYTES: usize = 64 << 20; /// The decoded streams of the `/DSS` evidence array `key` (`/CRLs`, `/OCSPs`), one at a time so /// each is checked before the next decodes: each stream once and within the evidence size cap, -/// at most [`MAX_DSS_ENTRIES`] of them and [`MAX_DSS_BYTES`] together. -fn dss_evidence<'a>(doc: &'a Document, dss: &Dict, key: &[u8]) -> impl Iterator> + 'a { - let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let mut items = items.into_iter().take(MAX_DSS_ENTRIES); - let mut seen = HashSet::new(); - let mut left = MAX_DSS_BYTES; - std::iter::from_fn(move || { - for item in items.by_ref() { - if left == 0 { - return None; - } - if item.as_ref().is_some_and(|r| !seen.insert(r)) { +/// at most [`MAX_DSS_ENTRIES`] distinct ones and [`MAX_DSS_BYTES`] together. A stream past a cap +/// sets [`DssEvidence::cut`] and ends the iteration. +struct DssEvidence<'a> { + doc: &'a Document, + items: std::vec::IntoIter, + seen: HashSet, + taken: usize, + left: usize, + /// A cap stopped the array being read in full. + cut: bool, +} + +impl<'a> DssEvidence<'a> { + fn new(doc: &'a Document, dss: &Dict, key: &[u8]) -> Self { + let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + DssEvidence { doc, items: items.into_iter(), seen: HashSet::new(), taken: 0, left: MAX_DSS_BYTES, cut: false } + } +} + +impl Iterator for DssEvidence<'_> { + type Item = Vec; + + fn next(&mut self) -> Option> { + if self.cut { + return None; + } + // Repeats are skipped before the caps count anything, so repeating one stream cannot use + // them up; the scan of the array itself is bounded by the parser's cap on array length. + for item in self.items.by_ref() { + if item.as_ref().is_some_and(|r| !self.seen.insert(r)) { continue; } - let o = doc.resolve(&item); + let o = self.doc.resolve(&item); let Object::Stream(s) = o.as_ref() else { continue }; - let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE.min(left)) else { continue }; - left = left.saturating_sub(bytes.len()); + if self.taken >= MAX_DSS_ENTRIES { + self.cut = true; + return None; + } + self.taken += 1; + // A stream past the evidence cap would not parse as evidence either. + let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE) else { continue }; + let Some(left) = self.left.checked_sub(bytes.len()) else { + self.cut = true; + return None; + }; + self.left = left; return Some(bytes); } None - }) + } +} + +/// What the catalog's `/DSS` says about the signer's certificate. +#[derive(Clone, Copy, Debug, PartialEq)] +enum DssRevocation { + /// Verified evidence revokes it. + Revoked, + /// A cap stopped the evidence being read in full, and what was read does not revoke it. + Incomplete, + /// All of the evidence was read, and none of it revokes it. + NotRevoked, } /// Check the revocation evidence in the catalog's `/DSS` against the signer's chain at `at`. /// Only evidence whose signature verifies against the issuer counts; returns whether the -/// certificate is revoked (which invalidates the signature). -fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Option