From b56ccad4fd8097bf4a5947a494a5181a69303c2a Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 12 Oct 2026 22:42:12 +0100 Subject: [PATCH 001/254] 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 44a26604d..d8494f382 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 1f9105e21..a8b7ed542 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 2d4f5b099..be1f2e900 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 a10cb0b0a..68f525acc 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 20fb73344..ee1256a49 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 eeba76118..d99296efa 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/254] 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 68f525acc..27778957c 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 ee1256a49..415b3eea2 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/254] 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 27778957c..3549753f7 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 415b3eea2..b53623b6b 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/254] 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 e4fe4a221..40d7322d9 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 6bf6dc9c7..1972a4271 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 37cafd440..25b1eb076 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 3549753f7..64d44e714 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 b53623b6b..906551554 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 ff9f2b017..17fa5103b 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/254] 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 d7eb8a2ba..aff732462 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 cfdeb1c16..558b2968c 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 0ff3930b4..3249cff55 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 991c86a9c..6f7e25f85 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 c3294dc1e..5fd78441d 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 15c0551b1..97a46bcc0 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 000000000..d785f1a4d --- /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 c420d2d59..37684882c 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 7b366bad9..f008bb11b 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 e07af72a1..4bb3b1e65 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 7d7b6b9c5..75dd71959 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 222865d72..0b2b8eed6 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 17a0890fa..73ae509bc 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 e6e29c8bb..5d78d5472 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 38ef665d1..b75f4913a 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 106278f01..b66a1bc6d 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 5afbe4f2e..43bbbbc35 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 79389837d..c2c3beec2 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 3d6e1e3af..9d215c571 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 3575ab374..282db62fc 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 5e61802c3..a33b764e7 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 8e4a09040..5d7659133 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 ae61e396f..410bd7c7b 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 310a990f7..395b90503 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 623d69ce8..cd6da1c53 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 7f628088d..cc7c4d2b2 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 3feaad354..1ef69378f 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 d15365f3d..4ef063f9b 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 44fd05f07..eed722a89 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 648dc855f..c77239d52 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 391c8eda7..df6b067e7 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 da45921c2..db8ebd156 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 ea3da0095..9e952b1c3 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 bdd625804..66d8fa07a 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 8f4194b93..e3d02b671 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/254] 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 28785f767..0bb17a96f 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/254] 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 e8999e88e..90d9c8ef6 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/254] 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 fe9723fdf..0b48c519e 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 5e89aef78..76e34de24 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 1e662d8f3..9ab1292eb 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 4bf80e3f7..86492bc04 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 8958688d2..0497594e1 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 ed90eea41..f8e898564 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 5d494d7b4..0d4cdf9ed 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 3148823ea..7a4b52c86 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 533013b54..cdce9fb60 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/254] 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 588539866..ef18a121f 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 d34b1e431..c1e20a132 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/254] 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 4a60c40a6..e994c7911 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 0a0e45b15..fa5d7f884 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 c3d319f94..c5bb50420 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/254] 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 91ed3c490..0d894e46a 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 259dbfec0..f2cfb655a 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 184ee9697..c00bfba75 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 32915af6b..10a5f4d7e 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 b5967eee0..302c4e532 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 8cfa82674..1a9a918ac 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 c8b9b6769..0d9737d68 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 000000000..77a329352 --- /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 c3d319f94..29dd75701 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/254] 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 259dbfec0..577370004 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 1e662d8f3..6d778bb99 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 b542142df..a8c0b2349 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 9c83cf9a3..8f9adfe40 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 2d7baf519..5078b3d67 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 e8bb03b9a..ee6e4b0e4 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 3dfea674f..356634bb4 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 6dbc34f35..fece02ef5 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/254] 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 259dbfec0..6bb2c0833 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 8ba3ca8c4..55df6d1a7 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 f4973aa02..590848cb2 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 302688f72..109e4acf3 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 88856305b..e1abeaa6c 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 3432cd868..fd8c66cac 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 3c3988b7b..fb2b5a9c7 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 8d5686dd8..04f2f46b6 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 0acab64a0..3704a98ea 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 5a708c14f..b677431fe 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 c5fbbd0ce..482d40e08 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 914bc281b..f479aebe4 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 c3d319f94..72ee77962 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 62be4cad9..6a815d22b 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 fe08fc284..a74f417f3 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/254] 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 577370004..c6a99283f 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 a8c0b2349..e48d148f3 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 5078b3d67..3ebdd0036 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 356634bb4..ef03aca8f 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 fece02ef5..3ae80e308 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/254] 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 27db0e98c..1c10a0a83 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 a4c2e8cf7..858e4b8c1 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 d69148807..38d5dcd37 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 fb0e75028..46bfc24f4 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/254] 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 88856305b..eb320da76 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 5e89aef78..b967a526a 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 1e662d8f3..b69a6b207 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 a0b1f56ee..f016b2ae4 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 ee8c957f9..3616ecd47 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 c8b9b6769..1b84cadac 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 2a83e8d6a..e9689af52 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 34188e184..9660b0a92 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 ce2a5f37e..4d80c4fe6 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 157e9e368..395b95bd7 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/254] 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 445ffe118..39931a02a 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/254] 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 27d5a87d3..56e2c90d7 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/254] 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 ee84a1111..453346fa3 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 32915af6b..7a13e5bd8 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 1b84cadac..917787e2c 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 55e898b28..e56c79add 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 395b95bd7..b7bba39cd 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/254] 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 56e2c90d7..9dc6bb220 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/254] 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 eb320da76..ee0dc4cd7 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 b69a6b207..55b08f6ba 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/254] 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 f125c41da..c89c5aac7 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 2285f36e0..327b2368d 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 54bb9bc21..e3ab415e6 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 bf4414970..8711df9fe 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 cca62e64e..a84ed9c42 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 17a037664..a3ac812eb 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 03eb2c67a..3dd84222c 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 b1805d522..5f9fc9b9d 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/254] 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 ad7483465..1a412ff63 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 327b2368d..34bc52386 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/254] 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 3ad431797..6c6b9fb34 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 64d44e714..0dcd5c9a6 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 906551554..531209634 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/254] 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 0dcd5c9a6..34f023d6d 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 531209634..86740c451 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/254] 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 34f023d6d..dcd355375 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 86740c451..872768c54 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/254] 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 66ad98ffe..f9a028a0e 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 91480ac7b..6220dc064 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/254] 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 dc32d457b..43f32a11b 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 d8d9fa8ba..0e7de9891 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 61d3a71e5..791da93a2 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 a2361da7e..9a48ff16a 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 7aca7d5c8..b8c0c9f36 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/254] 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 dfe11b25c..3b21c952e 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 9365e744c..8d4745add 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 0f37933af..8f8e0a387 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 33dbb017e..b7ae13482 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 b7c2605a6..f6f4ab18d 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 bf4414970..d6f4837be 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 000000000..58bd48cd6 --- /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 0a192479a..bdf6b6fbd 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/254] =?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 212454973..3a1422ec7 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 b797bc8d8..82ee90c5d 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 f125c41da..72854e37a 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 0f37933af..313bfd1ca 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 d8d9fa8ba..987d28da4 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 61d3a71e5..bfb53996f 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 a2361da7e..8f64f9049 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 df461fc6e..98b2afbd8 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 e153dcca9..6b2add486 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 7aca7d5c8..f47140442 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/254] 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 82ee90c5d..73be05660 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 72854e37a..4c19b8011 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/254] 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 8e4acdf89..1941c1858 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 000000000..25b0d6c60 --- /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 d785f1a4d..3705ad9ed 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 000000000..3a5145e71 --- /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/254] 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 1941c1858..932a3e568 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/254] 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 3a1422ec7..a9e13cbc3 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 987d28da4..281898c4a 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 bfb53996f..ec4cfcc5e 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 f47140442..12e11d82c 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/254] 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 281898c4a..aff27f951 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/254] 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 0e7de9891..8f3bb412d 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 791da93a2..c6b6d80d3 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 9a48ff16a..ef06e8418 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/254] 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 a9e13cbc3..19afb16a4 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 73be05660..9b057900f 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 4c19b8011..6b0a97e98 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 aff27f951..1eb3d60e8 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 ec4cfcc5e..a78ce67de 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 8f64f9049..093ba633b 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 6b2add486..47a5c8b52 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/254] 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 3a5145e71..b24a4611b 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/254] 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 eb9244a28..b7abd2bb7 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 b769c0c00..52766620b 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 9db924759..2923788de 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 d41041e52..23451ee2a 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 b15469485..224e2ff8a 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 7aca7d5c8..9ddac693c 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/254] 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 19afb16a4..a11f1132f 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 9b057900f..025037b8f 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 1eb3d60e8..6b4e7f875 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 a78ce67de..e3ce86efe 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 093ba633b..6b87b2e57 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 47a5c8b52..85b0ac03d 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/254] 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 52ca6c276..3d03878e8 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 da1ecf12a..9d16fd534 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/254] 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 b7abd2bb7..36552ddd1 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 2923788de..6d1f7e70a 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/254] 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 36552ddd1..79e21538e 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 5f5ef32f7..b1ba7e4f9 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 53e4bdf15..403e9836c 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 6d1f7e70a..f2dae65a8 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/254] 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 5888e5111..3fd67e3bb 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 9576d59a8..14b3e617c 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/254] 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 dcd355375..039384da8 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/254] 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 039384da8..186e5753c 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/254] 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 7fd075dd8..44993b6ca 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 000000000..ac1aed736 --- /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/254] 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 b871ed6c3..d15525163 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/254] 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 212454973..423e6d251 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 3c53563be..1266db010 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 8926a1368..a69f56517 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 55b10662f..4820743e2 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 26e7b9d06..00e430b30 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 c90240875..f5c08ce10 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 f5fda5be9..84cc21b25 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 c7a043c89..743a36ff5 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/254] 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 95ace8c2d..d8a680880 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 dbae4ef91..87875211f 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/254] 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 80f90fb2a..4f3b1ffd3 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 7a80e38e4..f79a4e95f 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/254] 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 26e7b9d06..763fd0efd 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 bf6169f42..9cf0b768c 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 e20cbc596..7255cfec0 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 3b6f6f20d..651f8e320 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 b2e4994ec..84ce87984 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/254] 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 f91e4bf3f..d80ecca3e 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 9082c689c..516d85ed6 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 c06f26e24..423d1814b 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/254] 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 9e76fbbae..9b4988aef 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 58bd48cd6..2da57a2b0 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 87875211f..da9c8832e 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/254] 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 9b4988aef..cf9bf7ff5 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 2da57a2b0..3ddb39211 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 da9c8832e..24872b35d 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/254] 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 3299f9d6e..4a4b5e64b 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/254] 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 3d03878e8..bc34cc00b 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 9d16fd534..115dbabd9 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/254] 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 651f8e320..9b52c5a33 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 84ce87984..237b9d109 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/254] 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 bc057209c..dc7f544d7 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 64b22ff18..505f3e6a0 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 2cd232425..5adb41696 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 6e86c5099..11d6a94df 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 362c2aa77..081dcf7e5 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/254] 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 11d6a94df..1559838dd 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 14f34086c..758aa1a27 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/254] 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 765bbba55..00e0aeed3 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 9bb377a93..00f89fa74 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 9cad6ad5e..7e5f89039 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 804e7a97a..e4a7362c1 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 4e736bbf4..7da850f2b 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 76366d452..1bde60ae1 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 18464664a..7d6922552 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 db00c69dd..2dfc38749 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 1ddc29982..81b796471 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 ec6e59e56..490837d56 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 7be5ec688..bcdcac9a3 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 f1723629c..ba244a898 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 3585b344e..58a461382 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 0dc6bb087..49f622af5 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 963e8d364..73639521f 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 311626f34..907877336 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 48ef31e19..bfda232cc 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 a8a9f6f64..4aa8e4a6d 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 0e542211f..2b9d886ec 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 dff8efc3d..76d530dc1 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 27c1383ec..62a2a9a49 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 cd381b539..c0e0d03eb 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 8fe72f99b..3e136ff00 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 5a8025dc8..ef0eb30b9 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 0dc40fdba..681a41a2f 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 f5e374ae7..5fc2c3a62 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 c93efee5a..a89c990b2 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 fa3f6cec4..3b7d879d5 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 e56770757..d4c023015 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 c292b8863..64d8aa154 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 cc8f45765..96136a425 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 bb20dcc5b..d60a8de88 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 594f68df8..fc2bb4007 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 cb578ba26..f0ac381b4 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 d80ecca3e..658457499 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/254] 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 0337a8cbf..f4b2aca82 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 1ec9d383f..299578649 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 e4ce56d7c..ade218aaa 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 362c2aa77..f0ea5933f 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 19f40e6554415bd897c5315e4863762625d3a557 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 21:21:00 +0500 Subject: [PATCH 063/254] 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 14b3e617c..7955157e2 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 064/254] 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 64b22ff18..aea4b6f84 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 029dc5bc9..7f81f76a2 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 71ba9ccda..38c4ddbdb 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 362c2aa77..db4cc2819 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 065/254] 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 bc057209c..9164899c0 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 d80f988b7..541e1c8ad 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 64b22ff18..d618e4ba2 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 6194c7516..5e6227843 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 c12461793..703f1e9d8 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 362c2aa77..d55fe67f6 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 066/254] 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 593ae6e68..f0de55d67 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 3f42a5401..1fcac5f6d 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 efcf3e3c6..66808a1cd 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 ccacccd09..b5e78ece1 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 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 067/254] 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 f9a028a0e..57a17f5a0 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 6220dc064..992eb63a3 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 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 068/254] 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 230addd65..64440612c 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 caf189522fac9d239f4897e4884032a4f2c81f87 Mon Sep 17 00:00:00 2001 From: Hitesh Date: Thu, 22 Oct 2026 23:18:01 +0530 Subject: [PATCH 069/254] 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 7f81f76a2..04e37feee 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 38c4ddbdb..4a35390c9 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 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 070/254] 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 82a801dee..cd555a989 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 61517b53d..cd0b713a8 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 071/254] 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 82a801dee..1e03c6ff3 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 61517b53d..0b0f91654 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 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 072/254] 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 cd555a989..0cd58d7f9 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 cd0b713a8..b3a025fc2 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 60ce45f9badce7ad655a70537fe082cb82a1d792 Mon Sep 17 00:00:00 2001 From: Oliver Hagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:08:35 +0200 Subject: [PATCH 073/254] Flatten comments from the page's right-click menu and the Comments panel's menu Reviewers flatten their comments and Fill & Sign signature once done, so nothing on the signed or reviewed pages moves any more. The command was only in All tools and the palette; it now also ends the page's right-click menu (with or without a comment selected, while the document has comments) and the Comments panel's More options menu. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/comments.rs | 20 +++++++++++++- crates/ui-egui/src/comments_panel.rs | 6 ++++ crates/ui-egui/tests/it/comments.rs | 41 ++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 5 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8df86fb3..2e37f9638 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4031,7 +4031,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.separator(); } } - canvas_action = comments::context_menu(ui, view, info, prefs, allowed).or(copy_image); + canvas_action = comments::context_menu(ui, view, info, prefs, allowed, can_modify).or(copy_image); }); (wanted, visible_now) }); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index d80f988b7..92d1d6a6d 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1841,7 +1841,15 @@ pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &Doc } /// Items of the canvas context menu for the selected comment (or the page). -pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option { +/// `can_modify`: the document's pages may change (Flatten comments draws into them). +pub(crate) fn context_menu( + ui: &mut egui::Ui, + view: &mut DocView, + info: &DocInfo, + prefs: &CommentPrefs, + allowed: bool, + can_modify: bool, +) -> Option { let mut action = None; let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && a.in_reply_to.is_none())); match selected { @@ -1925,6 +1933,16 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo } } } + // Every comment of the document into its pages, as Comment ▸ Flatten comments does: signed + // or reviewed pages whose comments should no longer move. + if !info.annotations.is_empty() { + ui.separator(); + if ui.add_enabled(can_modify, egui::Button::new(tl!("Flatten comments"))).clicked() { + view.comments.selected = None; + action = Some(CanvasAction::Edit(Box::new(Edit::Flatten { comments: true, fields: false }))); + ui.close(); + } + } action } diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index ff8570456..6247021a9 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -585,6 +585,12 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV ui.close(); } } + // Into the pages, for good once saved: apart from the list's other commands. + ui.separator(); + if ui.button(tl!("Flatten comments")).clicked() { + command = Some("comment.flatten"); + ui.close(); + } }); let filter = icons::button(ui, "filter", 26.0, active, tl!("Filter comments")); egui::Popup::menu(&filter).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 6194c7516..57bcc2c59 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1265,6 +1265,47 @@ fn page_groups_collapse_and_expand() { assert!(shown(&h, "First page note") && shown(&h, "Second page note"), "every page is open again"); } +/// Flatten comments is in the page's right-click menu and in the Comments panel's "…" menu: the +/// comments go into the page, one undo step brings them back. +#[test] +fn flatten_comments_from_the_page_menu_and_the_panel_menu() { + let mut h = harness(|app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "select").unwrap(); + }); + let shape = pdfcraft_engine::Shape::Rectangle { rect: [20.0, 20.0, 80.0, 60.0] }; + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: "Reviewed".into(), + author: "Tester".into(), + })); + h.state_mut().views[0].comments.selected = None; + settle(&mut h); + assert_eq!(comments(&h).len(), 1); + + // Right-click on the page, away from the comment. + let p = at(&h, 200.0, 150.0); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + h.get_by_label("Flatten comments").click(); + h.run_steps(4); + assert!(comments(&h).is_empty(), "the comment went into the page"); + h.state_mut().undo(); + h.run_steps(3); + assert_eq!(comments(&h).len(), 1, "one undo step brings it back"); + + h.get_by_label("More options").click(); + h.run_steps(2); + h.get_by_label("Flatten comments").click(); + h.run_steps(4); + assert!(comments(&h).is_empty(), "the panel's menu flattens too"); +} + #[test] fn comments_take_checkmarks_lock_hide_and_summarize() { let mut h = harness(|app| { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 362c2aa77..b0b81f81b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4232,8 +4232,8 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_flatten"] -evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/it/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments"] -notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)" +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/it/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments", "crates/ui-egui/tests/it/comments.rs::flatten_comments_from_the_page_menu_and_the_panel_menu"] +notes = "also in the page's right-click menu and the Comments panel's More options menu; appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)" [[feature]] id = "comment.migrate" From 31828efd6ecb2179fd11e2edf4fb7868333c1f45 Mon Sep 17 00:00:00 2001 From: Lombardi2003 Date: Thu, 22 Oct 2026 17:43:39 +0200 Subject: [PATCH 074/254] feat(ui): the secured-document notice can be turned off for good (#4035) The notice that a document is secured came back on every open; Dismiss lasted only for that view. Its new "Don't show again" turns it off in every document, kept in the settings as show_secured_notice and turned back on in Preferences (or --secured-notice). Only the notice changes: the restrictions, the encryption and Document Properties > Security stay as they are. The two labels are translated in all 33 catalogues, whose ATTRIBUTION hashes and provenance notes are refreshed. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 66 +++++++------- ATTRIBUTION.toml | 132 ++++++++++++++-------------- crates/ui-egui/src/canvas.rs | 18 +++- crates/ui-egui/src/i18n/ar.tsv | 2 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/ckb.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/kk.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/lo.tsv | 2 + crates/ui-egui/src/i18n/lt.tsv | 2 + crates/ui-egui/src/i18n/nb.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ro.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sl.tsv | 2 + crates/ui-egui/src/i18n/sq.tsv | 2 + crates/ui-egui/src/i18n/te.tsv | 2 + crates/ui-egui/src/i18n/th.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/js_ui.rs | 2 + crates/ui-egui/src/lib.rs | 16 ++++ crates/ui-egui/tests/it/editing.rs | 55 ++++++++++++ parity/acrobat-features.toml | 4 +- 40 files changed, 256 insertions(+), 103 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 593ae6e68..01668750f 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -8,33 +8,33 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| -| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese 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 | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `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; 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; 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 | -| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ja.tsv` | Japanese 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 | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian 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 | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ar.tsv` | Arabic 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 | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) 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 | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/tr.tsv` | Turkish 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 | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian 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; original catalog reviewed by a native Lithuanian-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 | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/hi.tsv` | Hindi 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 | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `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/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese 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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice labels 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; 2026-10-22 secured-document notice labels 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; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice 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/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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice 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/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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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 | +| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, 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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ja.tsv` | Japanese 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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian 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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ar.tsv` | Arabic 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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) 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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/tr.tsv` | Turkish 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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian 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; original catalog reviewed by a native Lithuanian-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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/hi.tsv` | Hindi 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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 | | `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 | @@ -285,17 +285,17 @@ 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; 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 | +| `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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice 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 | +| `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; 2026-10-22 secured-document notice 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 | | `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/sq.tsv` | Albanian 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 signature-appearance 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 | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/th.tsv` | Thai 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 | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; 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 | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/sq.tsv` | Albanian 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 signature-appearance 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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/th.tsv` | Thai 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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | ## Compiled in through dependencies (5) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 3f42a5401..91a6b8d5e 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2,12 +2,12 @@ path = "crates/ui-egui/src/i18n/el.tsv" title = "Greek interface translation catalog" author = "Camthalion77 and contributors" -source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 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 English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels 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 = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1902f1c31698e6adfdaf65b35b1fe1628f5cd2bd0182dd3b99bcf5e21720f394" +sha256 = "9936db8d856180cbf99c900045f473eccf32682888f114f8cccc715fa1ac741e" # 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`). @@ -25,286 +25,286 @@ sha256 = "1902f1c31698e6adfdaf65b35b1fe1628f5cd2bd0182dd3b99bcf5e21720f394" path = "crates/ui-egui/src/i18n/zh-hans.tsv" title = "Simplified Chinese 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 secured-document notice labels 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 = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2e23c41fcd5b9118cfde03b3a45aa2f988f7eeba650677b6f1f6664e543c7a59" +sha256 = "5dcad6a1d74310ee039c6c1374094282dbddaff74eac3c4c38520bf1e829abc5" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" title = "Traditional Chinese (Taiwan) 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 secured-document notice labels 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 = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "105703f2fbc07c0663308ebd6852199de12a5851a37507d7e51e477eb5e57218" +sha256 = "c0283fc45e66d3d3dfbba9bd29a2e849fc989864be9fecd647cc900767174f18" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" title = "Czech 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; 2026-10-22 signature-check and standard-identification wordings 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 and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels 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 = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b08f8282923d2faaf1f658d2aff8f71fe5f2d3a33e792eeb50fb0e2b396ef24f" +sha256 = "49bdbac55d0f4b7e510f49e0f434bccfae47dbae7beae98dd61f72a1333a6fb6" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" title = "Brazilian Portuguese interface translation catalog" author = "André Cardoso (andrecards), PdfCraft contributors" -source = "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" +source = "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; 2026-10-22 secured-document notice labels 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 = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "df17dada3df736e75bd9b03e5ecceec164cce63767e33a1efdb98c56a121c890" +sha256 = "2040e2b5409d5793ace20ec490d501459a8f37371806d1942402c8504a61b9c7" [[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; 2026-10-22 signature-check wordings 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; 2026-10-22 secured-document notice labels 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 = "8e8c0cfda0886c584e5286370ec00814206c3af3699c76665e636fa6fdb6310b" +sha256 = "d4b2cd5868951d066e6e1ba1d3041ba22c89727ee643cae4b68c1d523d048f76" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" title = "French 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 secured-document notice labels 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 = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3f31e5c9abdb0208d48d8c68aa81437d9d1e10aeb615dedf811fefa539d93279" +sha256 = "ce62deb099dcac4c776a4344e5c9ae245729252f5ae03ba17b0ca00d9f39bea1" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" title = "German 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 secured-document notice labels 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 = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "815a3a672421cc3590fb1f267755316659cdb2046e30bbb3ad8b31e96f08b20a" +sha256 = "4910828b94dff642224e15213af17e6347904b1bb07c94f29b322df866ac0b0a" [[asset]] path = "crates/ui-egui/src/i18n/lo.tsv" title = "Lao interface translation catalog" author = "@bounkirdni-2025" -source = "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" +source = "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; 2026-10-22 secured-document notice labels 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 = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6bd67286795ed487eedf2ce549249c8a83286cb4273490c1349996050b540fed" +sha256 = "7b83ab2d16ad490ef96f278e0cad6e88c135bf2c84cff0b7e1afb0af8dfa797f" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" title = "Telugu 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 secured-document notice labels 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 = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9cd63425705fb9a3aa029ff3a5110d0877a34f9038f1d64a2102ccd6c3cb35a6" +sha256 = "fe6f2573868155342119258c82b1ca1c544c0069b01910e99428333fe975aa88" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" title = "Italian 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 secured-document notice labels 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 = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "76f138293fc12ea3584568a81f6f90a1cedfff6256e78b4f60cb21b1b66bbba4" +sha256 = "4fcee87fa4aec634a97a38f80d1ae1a8ec9ccaa4bd36e23009384eff521b5d5e" [[asset]] 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; 2026-10-22 signature-check wordings 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; 2026-10-22 secured-document notice labels 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 = "0bd75a93f8d18deb832223bd0642cc4f4abde04a322b684a143a9d4b4687a344" +sha256 = "1883f5f41cded31feb9662fcd3b145502bea90f129dd52447a287ac9d58fe202" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" title = "Russian 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 secured-document notice labels 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 = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3d539a28afcf8c2e36b7087ac23334b50bac7971e03da20a62b6e7e400763a87" +sha256 = "720d53384dd9f4395555b2237d8789aa3ada42bbe13aef28dabeac9e9ee18cdc" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" title = "Hungarian 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 secured-document notice labels 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 = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "71b7e8180fc759aebad4660953ab8b55383def0572c1ace2288cd8bfeb1b82dc" +sha256 = "1991108c6721733484caab092af73f19fa41851cf5621c571f465e0168177c6c" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" title = "Bulgarian 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 secured-document notice labels 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 = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e689fb24a8acfa7e85f0f60644f7e2da1225595e6cd6a94109fbe86082926244" +sha256 = "f86a42215104d0e67acae28d7243f5f27044050b5f3f098d70625f0804e59465" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" title = "Kazakh interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, 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 the English UI labels into Kazakh, 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 secured-document notice labels 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 = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c601ea117ec2a3cdba606fdc9a62220fcffbd0c20c1133bacb653a979cf046a5" +sha256 = "a6437d5314bee94148b3e41fc37f44cf442ab28427fb7f103e3db0b735185081" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" title = "Japanese 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 secured-document notice labels 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 = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "006867b891ae2f995c7a711e55b6401db29d20ab493889c6284dc3671f9faa2d" +sha256 = "534e0b4c6cd7301de1a5fb9bd64370617680dd43a9164011e370d0132f20f2a3" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" title = "Ukrainian 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 secured-document notice labels 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 = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9e5b18a09c209525cf08ef53a242ed457cfa346295eef7facde482a75cf72f52" +sha256 = "b61e855509fdb5541923a9af81b5d226448e1b16f35bd4f944fb8a5667286291" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" title = "Arabic 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 secured-document notice labels 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 = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2306dbbe6a73671fd20df116adb159a7f66c85fbbd1bae5ecef5f6e882d6b7f1" +sha256 = "ac2ec915beaec3623147c892659f350facc828fb635ef135e94b3f5c9ee18e22" [[asset]] path = "crates/ui-egui/src/i18n/ckb.tsv" title = "Central Kurdish (Sorani) interface translation catalog" author = "PdfCraft contributors" -source = "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" +source = "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 secured-document notice labels 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 = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "864db8aa0a5e443c92e7e388f89349eb0f0e7beda35a0b81695d864dcec51b72" +sha256 = "9b0cc7a1f34581eb93b50378ab2391b129631cdb54482cf8df36464529280d32" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" title = "Turkish 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 secured-document notice labels 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 = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "463a2d28bd5c5ac8df31a5b5c68400d7a61ce51237a58d9337078c973e6f9e3d" +sha256 = "cf7a40ea2dca1c629a147a7bb44d4298bf3785e756133b0531f827de63496263" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" title = "Lithuanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-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 and user-facing messages; original catalog reviewed by a native Lithuanian-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 secured-document notice labels 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 = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bbef7f4e16f83e9e0116278e7a0a175bad79757f96a18dc639516572533f6d5b" +sha256 = "c21c9241e404692ce924db063b3fcd1200f3e9fd8156f90cfdc9a3669e5fe11b" [[asset]] path = "crates/ui-egui/src/i18n/hi.tsv" title = "Hindi interface translation catalog" author = "PdfCraft contributors" -source = "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" +source = "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; 2026-10-22 secured-document notice labels 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 = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "09d2840c02b40a6049b86c2cb68151d863913e8c91c22226f25d07496c6366aa" +sha256 = "449cef4ff4562ec9f4307c0bb58cd2d4cb11ed08fd605e44018b1f2867fdef60" [[asset]] path = "crates/ui-egui/src/i18n/id.tsv" title = "Indonesian interface translation catalog" author = "PdfCraft contributors" -source = "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" +source = "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; 2026-10-22 secured-document notice labels 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 = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0dc1abdb262e4568f19ffb2233bfeb3686ae42df8a8d8bec2951351a50e6a886" +sha256 = "0db268f03975057cef68ffd21154be3943c7446fbfdfcfb9a050471bbb78ac91" [[asset]] path = "crates/ui-egui/src/i18n/ko.tsv" title = "Korean interface translation catalog" author = "PdfCraft contributors" -source = "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" +source = "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; 2026-10-22 secured-document notice labels 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 = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a3e67cee4f3fff776e816523f810b2bb6353235dbdf85181c3f12896604a3339" +sha256 = "76d6bab93760f1e269ff5ae07116bc37af0e797ce1d0d1f568b54a9bf20999f9" [[asset]] path = "crates/ui-egui/src/i18n/ro.tsv" title = "Romanian interface translation catalog" author = "PdfCraft contributors" -source = "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" +source = "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; 2026-10-22 secured-document notice labels 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 = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "00a2153c20a191fbe04f1ce61f140fd9628cfe2f8f75dad5bce8a7406e1bd091" +sha256 = "35dcfa9325501e863ed199e09929f47fdcbd29a8bdff7452ceebc457d85a36f8" [[asset]] 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 secured-document notice labels 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 = "9c1a32c22fa53dddb2cfae4239bddc87e2340fc00eabc965e83bdc523b782fa7" [[asset]] path = "assets/icons/accessibility.svg" @@ -3421,23 +3421,23 @@ 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; 2026-10-22 signature-check wordings 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; 2026-10-22 secured-document notice labels 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 = "57f0dd007ad16518fbc87d8aeac840ef6d86412bb460aff9fb17b176408fb7a8" +sha256 = "3c8f9f3a90d320cf51459638111316d7e4bd3edba8bb55e68ea3177772e3ae2d" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" title = "Slovene 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 secured-document notice labels 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 = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a3b8351f0da80c2d394221dbe5d62e794bb019c74c2adfc5d933d999f7d3236f" +sha256 = "f6173f8325e229b5e9834e4d9994a0b18b246b4aa1faa1f5c844d6feff9ad993" [[asset]] @@ -3455,12 +3455,12 @@ sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697" path = "crates/ui-egui/src/i18n/nb.tsv" title = "Norwegian Bokmål interface translation catalog" author = "PdfCraft contributors" -source = "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" +source = "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 secured-document notice labels 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 = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "03477c4ab2d0d52543cb91b3aec6f27b2689e6b9bb7c95bbb8463664c75a9dc0" +sha256 = "883feeec906790d1a7a9533dbc670103abe3f2a092ee7d0ab32dcbcfe68dfd5c" [[asset]] path = "assets/icons/superscript.svg" @@ -3510,31 +3510,31 @@ sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7" path = "crates/ui-egui/src/i18n/sq.tsv" title = "Albanian interface translation catalog" author = "PdfCraft contributors" -source = "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 signature-appearance 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: 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 signature-appearance 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 secured-document notice labels 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 = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ebd177262d8fe20f960f06df084f8a03c9504bcd9990dded3a006e35bd8ef7c9" +sha256 = "29b662169e44fa704389ab0d03fc6682d3c03908cfed429080e74a71f237f99a" [[asset]] path = "crates/ui-egui/src/i18n/th.tsv" title = "Thai interface translation catalog" author = "PdfCraft contributors" -source = "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" +source = "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 secured-document notice labels 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 = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1dbb66c05af0aec06d5a6fa154e89f1684203dde32c8e8aedd9aeec6c2d5ae02" +sha256 = "f5c1f2a85ae52573f73aae5a64291d42ead8d944ec3d6648663b1d921a8428cb" [[asset]] path = "crates/ui-egui/src/i18n/ca.tsv" title = "Catalan interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; 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" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; 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; 2026-10-22 secured-document notice labels 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 = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5879ee3540ffb630fc83d0f2cb293cc6cbf2e081ea4577161a770794e8cbd22c" +sha256 = "0d834abc851884648c46ab26b89712e69092b17ec70b8459fc0b87aa9b36272f" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8df86fb3..45265fe9d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2657,9 +2657,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let editing_content = (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); let text_style = app.text_style.clone(); + let show_secured_notice = app.show_secured_notice; let view = &mut app.views[index]; - // Opened without the owner password and something is restricted. - let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); + // Opened without the owner password and something is restricted. The notice says so unless + // the user turned it off (#4035): the restrictions themselves, and Document Properties ▸ + // Security, stay as they are. + let secured = show_secured_notice && doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); let repaired = !doc.repair_log().is_empty(); match notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t) { #[cfg(not(target_arch = "wasm32"))] @@ -2679,6 +2682,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), Some(Notice::FieldHighlights(on)) => app.view_defaults.highlight_fields = on, Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole), + // Back on in Preferences ▸ General. + Some(Notice::HideSecured) => app.show_secured_notice = false, None => {} } // No dialog, close prompt or palette over the page: only then does page input count. @@ -4754,6 +4759,8 @@ enum Notice { Unlock, /// Close this historical snapshot tab (View signed version / View revision). Close, + /// Stop showing the secured-document notice, in every document (#4035). + HideSecured, } /// The notice bar above the pages: a file changed on disk first, then what a historical @@ -4831,6 +4838,7 @@ fn notices( } let mut open_security = false; let mut unlock = false; + let mut hide_secured = false; let mut open_repairs = false; let mut open_console = false; let mut toggled = None; @@ -4912,6 +4920,9 @@ fn notices( if secured && doc.can_unlock() && crate::widgets::pill_button(ui, tl!("Unlock…"), true).clicked() { unlock = true; } + if secured && crate::widgets::pill_button(ui, tl!("Don't show again"), false).clicked() { + hide_secured = true; + } if repaired && !secured && info.fields.is_empty() && crate::widgets::pill_button(ui, tl!("Details"), false).clicked() { open_repairs = true; } @@ -4925,6 +4936,9 @@ fn notices( if unlock { return Some(Notice::Unlock); } + if hide_secured { + return Some(Notice::HideSecured); + } open_security.then_some(Notice::Security).or(toggled) } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 765bbba55..0ee83e33d 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2340,6 +2340,8 @@ key Space مسافة Printer paper and scaling may differ. Check driver settings. قد يختلف ورق الطابعة ومقياس الطباعة. تحقق من إعدادات برنامج التشغيل. Drawing scale changes. Use Actual size for full-scale plans. يتغير مقياس الرسم. استخدم الحجم الفعلي لطباعة المخططات بمقياسها الأصلي. Reload documents when their files change on disk إعادة تحميل المستندات عند تغيّر ملفاتها على القرص + Don't show again عدم الإظهار مرة أخرى + Show a notice when a document is secured إظهار إشعار عندما يكون المستند مؤمَّنًا Always use my default zoom and page layout استخدام التكبير وتخطيط الصفحة الافتراضيين دائمًا This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. تغيّر هذا الملف على القرص. «إعادة التحميل» تعرض النسخة الجديدة وتُسقط تغييراتك غير المحفوظة؛ و«حفظ» يستبدلها بهذه النسخة. Reload إعادة تحميل diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9bb377a93..c02411ea5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2282,6 +2282,8 @@ key Space Интервал Printer paper and scaling may differ. Check driver settings. Хартията и мащабът на принтера може да се различават. Проверете настройките на драйвера. Drawing scale changes. Use Actual size for full-scale plans. Мащабът на чертежа се променя. Използвайте Действителен размер за планове в оригинален мащаб. Reload documents when their files change on disk Презареждай документите, когато файловете им се променят на диска + Don't show again Не показвай отново + Show a notice when a document is secured Показвай известие, когато документът е защитен Always use my default zoom and page layout Винаги използвай моя мащаб и оформление на страниците по подразбиране This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Този файл беше променен на диска. „Презареждане“ показва новата версия и отхвърля незаписаните ви промени; „Запис“ я заменя с тази версия. Reload Презареди diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index dd2f497b8..550029169 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2137,6 +2137,8 @@ action wizard Start Inicia Home / End Inici / Fi H / Space (hold) H / Espai (mantén premut) Reload documents when their files change on disk Torna a carregar els documents quan els seus fitxers canviïn al disc + Don't show again No ho tornis a mostrar + Show a notice when a document is secured Mostra un avís quan un document està protegit This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Aquest fitxer ha canviat al disc. Torna'l a carregar per veure'n la versió nova; es perdran els canvis sense desar. Reload Torna a carregar Couldn't reload {name}: {e} No s'ha pogut tornar a carregar {name}: {e} diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 804e7a97a..0e3052789 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2303,6 +2303,8 @@ key Space بۆشایی Printer paper and scaling may differ. Check driver settings. ڕەنگە کاغەز و پێوەری چاپکەر جیاواز بن. ڕێکخستنەکانی درایڤەر بپشکنە. Drawing scale changes. Use Actual size for full-scale plans. پێوەری وێنەکێشان دەگۆڕێت. «قەبارەی ڕاستەقینە» بەکاربهێنە بۆ پلانەکان بە پێوەری تەواو. Reload documents when their files change on disk دووبارە بارکردنی بەڵگەنامەکان کاتێک فایلەکانیان لەسەر دیسک دەگۆڕێن + Don't show again جارێکی تر پیشان مەدە + Show a notice when a document is secured پیشاندانی ئاگادارکردنەوە کاتێک بەڵگەنامەیەک پارێزراوە This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. ئەم فایلە لەسەر دیسک گۆڕاوە. «دووبارە بارکردن» وەشانی نوێ پیشان دەدات و گۆڕانکارییە پاشەکەوتنەکراوەکانت فڕێدەدات؛ «پاشەکەوتکردن» بەم وەشانە جێگەی دەگرێتەوە. Reload دووبارە بارکردن Couldn't reload {name}: {e} دووبارە بارکردنی {name} نەکرا: {e} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 66cac29aa..236a4d36e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -140,6 +140,8 @@ Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Používá se, když PDF nepožaduje rozložení ani zvětšení. Plynulé posouvání mezi stránkami neskáče. Reopen the files that were open when PdfCraft last closed Znovu otevřít soubory, které byly otevřené při posledním zavření aplikace PdfCraft Reload documents when their files change on disk Znovu načítat dokumenty při změně jejich souborů na disku + Don't show again Znovu nezobrazovat + Show a notice when a document is secured Zobrazit upozornění, když je dokument zabezpečený Always use my default zoom and page layout Vždy používat mé výchozí zvětšení a rozvržení stránky This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Tento soubor se na disku změnil. Znovu načíst zobrazí novou verzi a zahodí neuložené změny; Uložit ji nahradí touto verzí. Reload Znovu načíst diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 76366d452..9a08bd7e6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2324,6 +2324,8 @@ key Space Leertaste Printer paper and scaling may differ. Check driver settings. Druckerpapier und Skalierung können abweichen. Treibereinstellungen prüfen. Drawing scale changes. Use Actual size for full-scale plans. Der Zeichnungsmaßstab ändert sich. Für maßstabsgetreue Pläne Tatsächliche Größe verwenden. Reload documents when their files change on disk Dokumente neu laden, wenn sich ihre Dateien auf dem Datenträger ändern + Don't show again Nicht mehr anzeigen + Show a notice when a document is secured Hinweis anzeigen, wenn ein Dokument geschützt ist Always use my default zoom and page layout Immer meinen Standardzoom und mein Standard-Seitenlayout verwenden This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Diese Datei wurde auf dem Datenträger geändert. „Neu laden“ zeigt die neue Version und verwirft Ihre ungespeicherten Änderungen; „Speichern“ ersetzt sie durch diese Version. Reload Neu laden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 18464664a..e2ac11ea2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2328,6 +2328,8 @@ comment menu Edit Επεξεργασία Printer paper and scaling may differ. Check driver settings. Το χαρτί και η κλίμακα του εκτυπωτή μπορεί να διαφέρουν. Ελέγξτε τις ρυθμίσεις του προγράμματος οδήγησης. Drawing scale changes. Use Actual size for full-scale plans. Η κλίμακα του σχεδίου αλλάζει. Για σχέδια σε πλήρη κλίμακα χρησιμοποιήστε το Πραγματικό μέγεθος. Reload documents when their files change on disk Επαναφόρτωση εγγράφων όταν αλλάζουν τα αρχεία τους στον δίσκο + Don't show again Να μην εμφανιστεί ξανά + Show a notice when a document is secured Εμφάνιση ειδοποίησης όταν ένα έγγραφο προστατεύεται Always use my default zoom and page layout Να χρησιμοποιείται πάντα η προεπιλεγμένη μεγέθυνση και διάταξη σελίδας μου This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Αυτό το αρχείο άλλαξε στον δίσκο. Η επαναφόρτωση δείχνει τη νέα έκδοση και απορρίπτει τις μη αποθηκευμένες αλλαγές σας· η αποθήκευση την αντικαθιστά με αυτή την έκδοση. Reload Επαναφόρτωση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 73b0ffbf1..4cd0fffb7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2193,6 +2193,8 @@ action wizard Start Iniciar Home / End Inicio / Fin H / Space (hold) H / Espacio (mantener) Reload documents when their files change on disk Recargar los documentos cuando sus archivos cambien en el disco + Don't show again No volver a mostrar + Show a notice when a document is secured Mostrar un aviso cuando un documento está protegido Always use my default zoom and page layout Usar siempre mi zoom y presentación de página predeterminados This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Este archivo cambió en el disco. Recargar muestra la versión nueva y descarta los cambios sin guardar; Guardar la reemplaza por esta versión. Reload Recargar diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index efcf3e3c6..1484273e3 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2268,6 +2268,8 @@ key Space Välilyönti Printer paper and scaling may differ. Check driver settings. Tulostimen paperi ja skaalaus voivat poiketa. Tarkista ohjaimen asetukset. Drawing scale changes. Use Actual size for full-scale plans. Piirustuksen mittakaava muuttuu. Käytä mittakaavaisille piirustuksille Todellinen koko -valintaa. Reload documents when their files change on disk Lataa asiakirjat uudelleen, kun niiden tiedostot muuttuvat levyllä + Don't show again Älä näytä uudelleen + Show a notice when a document is secured Näytä ilmoitus, kun asiakirja on suojattu This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Tämä tiedosto muuttui levyllä. Lataa se uudelleen nähdäksesi uuden version; tallentamattomat muutokset menetetään. Reload Lataa uudelleen Couldn't reload {name}: {e} Kohdetta {name} ei voitu ladata uudelleen: {e} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ec6e59e56..7abb710d7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2042,6 +2042,8 @@ Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Utilisé lorsqu'un PDF ne demande ni présentation ni zoom. Le défilement continu ne saute jamais d'une page à l'autre. Reopen the files that were open when PdfCraft last closed Rouvrir les fichiers qui étaient ouverts lors de la dernière fermeture de PdfCraft Reload documents when their files change on disk Recharger les documents lorsque leurs fichiers sont modifiés sur le disque + Don't show again Ne plus afficher + Show a notice when a document is secured Afficher un avis lorsqu’un document est protégé Always use my default zoom and page layout Toujours utiliser mon zoom et ma présentation de page par défaut This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ce fichier a été modifié sur le disque. Recharger affiche la nouvelle version et abandonne vos modifications non enregistrées ; Enregistrer la remplace par cette version. Reload Recharger diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7be5ec688..7ecafb060 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2250,6 +2250,8 @@ key Space Space Printer paper and scaling may differ. Check driver settings. प्रिंटर का काग़ज़ और स्केलिंग अलग हो सकते हैं। ड्राइवर सेटिंग्स जाँचें। Drawing scale changes. Use Actual size for full-scale plans. रेखाचित्र पैमाना बदल जाता है। पूर्ण-पैमाने योजनाओं के लिए “वास्तविक आकार” चुनें। Reload documents when their files change on disk जब उनकी फ़ाइलें डिस्क पर बदलें तो दस्तावेज़ दोबारा लोड करें + Don't show again फिर से न दिखाएँ + Show a notice when a document is secured दस्तावेज़ सुरक्षित होने पर सूचना दिखाएँ This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. यह फ़ाइल डिस्क पर बदल गई है। नया संस्करण देखने के लिए उसे दोबारा लोड करें; आपके असहेजे बदलाव खो जाएँगे। Reload दोबारा लोड करें Couldn't reload {name}: {e} {name} दोबारा लोड नहीं हो सका: {e} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f1723629c..e785247d6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2318,6 +2318,8 @@ key Space Szóköz Printer paper and scaling may differ. Check driver settings. A nyomtató papírja és méretezése eltérhet. Ellenőrizze az illesztőprogram beállításait. Drawing scale changes. Use Actual size for full-scale plans. A rajz méretaránya változik. Méretarányos tervekhez használja a Tényleges méret beállítást. Reload documents when their files change on disk Dokumentumok újratöltése, ha a fájljuk megváltozik a lemezen + Don't show again Ne jelenjen meg újra + Show a notice when a document is secured Értesítés megjelenítése, ha egy dokumentum védett Always use my default zoom and page layout Mindig az alapértelmezett nagyításom és oldalelrendezésem használata This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ez a fájl megváltozott a lemezen. Az Újratöltés az új változatot mutatja, és eldobja a nem mentett módosításokat; a Mentés erre a változatra cseréli le. Reload Újratöltés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3585b344e..0a7666895 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -924,6 +924,8 @@ Redaction code: Kode sensor: Reload Muat ulang Reload documents when their files change on disk Muat ulang dokumen saat file-nya berubah di disk + Don't show again Jangan tampilkan lagi + Show a notice when a document is secured Tampilkan pemberitahuan saat dokumen diamankan Remove checkmark Hapus centang Remove invalid links and bookmarks Hapus tautan dan bookmark yang tidak valid Remove page Hapus halaman diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0dc6bb087..ebd3b06cd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2328,6 +2328,8 @@ key Space Spazio Printer paper and scaling may differ. Check driver settings. Carta e scala della stampante possono differire. Controlla le impostazioni del driver. Drawing scale changes. Use Actual size for full-scale plans. La scala del disegno cambia. Usa Dimensioni effettive per le planimetrie in scala. Reload documents when their files change on disk Ricarica i documenti quando i loro file cambiano sul disco + Don't show again Non mostrare più + Show a notice when a document is secured Mostra un avviso quando un documento è protetto Always use my default zoom and page layout Usa sempre il mio ingrandimento e layout di pagina predefiniti This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Questo file è cambiato sul disco. Ricarica mostra la nuova versione e scarta le modifiche non salvate; Salva la sostituisce con questa versione. Reload Ricarica diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 963e8d364..f42e63d4c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2319,6 +2319,8 @@ key Space スペース Printer paper and scaling may differ. Check driver settings. プリンターの用紙や倍率は異なる場合があります。ドライバーの設定を確認してください。 Drawing scale changes. Use Actual size for full-scale plans. 図面の縮尺が変わります。原寸で印刷するには「実際のサイズ」を使用してください。 Reload documents when their files change on disk ディスク上のファイルが変更されたら文書を再読み込みする + Don't show again 今後表示しない + Show a notice when a document is secured 文書が保護されているときに通知を表示する Always use my default zoom and page layout 常に既定のズームとページレイアウトを使用する This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. このファイルはディスク上で変更されました。「再読み込み」は新しい版を表示して未保存の変更を破棄し、「保存」はこの版で置き換えます。 Reload 再読み込み diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 311626f34..632079b9b 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2318,6 +2318,8 @@ key Space Бос орын Printer paper and scaling may differ. Check driver settings. Принтер қағазы мен масштабы өзгеше болуы мүмкін. Драйвер параметрлерін тексеріңіз. Drawing scale changes. Use Actual size for full-scale plans. Сызба масштабы өзгереді. Толық масштабты жоспарлар үшін Нақты өлшемді пайдаланыңыз. Reload documents when their files change on disk Дискідегі файлдары өзгергенде құжаттарды қайта жүктеу + Don't show again Қайта көрсетпеу + Show a notice when a document is secured Құжат қорғалған кезде хабарландыру көрсету Always use my default zoom and page layout Әрқашан әдепкі масштаб пен бет орналасуын пайдалану This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Бұл файл дискіде өзгертілді. «Қайта жүктеу» жаңа нұсқаны көрсетіп, сақталмаған өзгерістеріңізді тастайды; «Сақтау» оны осы нұсқамен алмастырады. Reload Қайта жүктеу diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 48ef31e19..35d4f3a5b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2250,6 +2250,8 @@ key Space 스페이스 Printer paper and scaling may differ. Check driver settings. 프린터 용지와 배율이 다를 수 있습니다. 드라이버 설정을 확인하세요. Drawing scale changes. Use Actual size for full-scale plans. 도면 축척이 변경됩니다. 실제 크기 도면에는 '실제 크기'를 사용하세요. Reload documents when their files change on disk 파일이 디스크에서 변경되면 문서 다시 불러오기 + Don't show again 다시 표시 안 함 + Show a notice when a document is secured 문서가 보호되어 있으면 알림 표시 This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. 이 파일이 디스크에서 변경되었습니다. 새 버전을 보려면 다시 불러오세요. 저장하지 않은 변경 내용은 손실됩니다. Reload 다시 불러오기 Couldn't reload {name}: {e} {name}을(를) 다시 불러올 수 없습니다: {e} diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index a8a9f6f64..192b04eda 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2279,6 +2279,8 @@ key Space Space Printer paper and scaling may differ. Check driver settings. ເຈ້ຍເຄື່ອງພິມ ແລະ ມາດຕາສ່ວນອາດແຕກຕ່າງກັນ. ກວດສອບການຕັ້ງຄ່າໄດຣເວີ. Drawing scale changes. Use Actual size for full-scale plans. ມາດຕາສ່ວນຮູບແຕ້ມປ່ຽນແປງ. ໃຊ້ "ຂະໜາດຈິງ" ສຳລັບແບບແຜນເຕັມສ່ວນ. Reload documents when their files change on disk ໂຫຼດເອກະສານຄືນໃໝ່ເມື່ອໄຟລ໌ໃນດິສກ໌ມີການປ່ຽນແປງ + Don't show again ບໍ່ຕ້ອງສະແດງອີກ + Show a notice when a document is secured ສະແດງການແຈ້ງເຕືອນເມື່ອເອກະສານຖືກປ້ອງກັນໄວ້ This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. ໄຟລ໌ນີ້ມີການປ່ຽນແປງໃນດິສກ໌. "ໂຫຼດຄືນໃໝ່" ຈະສະແດງເວີຊັນໃໝ່ ແລະ ປະຖິ້ມການປ່ຽນແປງທີ່ຍັງບໍ່ໄດ້ບັນທຶກ; "ບັນທຶກ" ຈະຂຽນທັບດ້ວຍເວີຊັນນີ້. Reload ໂຫຼດຄືນໃໝ່ Couldn't reload {name}: {e} ບໍ່ສາມາດໂຫຼດ {name} ຄືນໃໝ່ໄດ້: {e} diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index e2476f196..7085f0842 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -975,6 +975,8 @@ Redaction code: Šalinimo kodas: Reload Įkelti iš naujo Reload documents when their files change on disk Iš naujo įkelti dokumentus, kai pasikeičia jų failai diske + Don't show again Daugiau nerodyti + Show a notice when a document is secured Rodyti pranešimą, kai dokumentas apsaugotas Always use my default zoom and page layout Visada naudoti mano numatytąjį mastelį ir puslapio išdėstymą Remove checkmark Pašalinti varnelę Remove invalid links and bookmarks Pašalinti netinkamus saitus ir žymeles diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 27c1383ec..b65774323 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -933,6 +933,8 @@ Redaction code: Sladdekode: Reload Last inn på nytt Reload documents when their files change on disk Last inn dokumenter på nytt når filene endres på disk + Don't show again Ikke vis igjen + Show a notice when a document is secured Vis et varsel når et dokument er sikret Remove checkmark Fjern hake Remove invalid links and bookmarks Fjern ugyldige lenker og bokmerker Remove page Fjern side diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6836f3e0a..03695e512 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2299,6 +2299,8 @@ key Space Spacja These objects could not be moved. Select them again and try a smaller move. Nie udało się przenieść tych obiektów. Zaznacz je ponownie i spróbuj mniejszego przesunięcia. This page contains objects that cannot be selected together. Ta strona zawiera obiekty, których nie można zaznaczyć razem. Reload documents when their files change on disk Wczytuj ponownie dokumenty, gdy ich pliki zmienią się na dysku + Don't show again Nie pokazuj ponownie + Show a notice when a document is secured Pokazuj powiadomienie, gdy dokument jest zabezpieczony Always use my default zoom and page layout Zawsze używaj mojego domyślnego powiększenia i układu stron This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ten plik zmienił się na dysku. Wczytaj ponownie pokazuje nową wersję i odrzuca niezapisane zmiany; Zapisz zastępuje ją tą wersją. Reload Wczytaj ponownie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dcbebae95..133fd903c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2321,6 +2321,8 @@ key Space Espaço Printer paper and scaling may differ. Check driver settings. O papel e a escala da impressora podem variar. Confira as configurações do driver. Drawing scale changes. Use Actual size for full-scale plans. A escala do desenho muda. Use Tamanho real para plantas em escala. Reload documents when their files change on disk Recarregar documentos quando os arquivos mudarem no disco + Don't show again Não mostrar novamente + Show a notice when a document is secured Mostrar um aviso quando um documento estiver protegido Always use my default zoom and page layout Sempre usar meu zoom e layout da página padrão This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Este arquivo foi alterado no disco. Recarregar mostra a nova versão e descarta suas alterações não salvas; Salvar a substitui por esta versão. Reload Recarregar diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 0003e3851..0df2223de 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -933,6 +933,8 @@ Redaction code: Cod de anonimizare: Reload Reîncărcare Reload documents when their files change on disk Reîncarcă documentele când fișierele lor se modifică pe disc + Don't show again Nu mai afișa + Show a notice when a document is secured Afișează o notificare când un document este securizat Remove checkmark Eliminare bifă Remove invalid links and bookmarks Eliminare linkuri și semne de carte nevalide Remove page Eliminare pagină diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0dc40fdba..6db3f5913 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2109,6 +2109,8 @@ Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Используется, когда PDF не запрашивает макет или масштаб. Непрерывная прокрутка никогда не привязывается между страницами. Reopen the files that were open when PdfCraft last closed Повторно открывать файлы, которые были открыты при последнем закрытии PdfCraft Reload documents when their files change on disk Перезагружать документы при изменении их файлов на диске + Don't show again Больше не показывать + Show a notice when a document is secured Показывать уведомление, когда документ защищён Always use my default zoom and page layout Всегда использовать мой масштаб и макет страницы по умолчанию This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Этот файл был изменён на диске. «Перезагрузить» покажет новую версию и отбросит несохранённые изменения; «Сохранить» заменит её этой версией. Reload Перезагрузить diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index f5e374ae7..703b9bda0 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2325,6 +2325,8 @@ key Space Preslednica Printer paper and scaling may differ. Check driver settings. Papir in merilo tiskalnika se lahko razlikujeta. Preverite nastavitve gonilnika. Drawing scale changes. Use Actual size for full-scale plans. Merilo risbe se spremeni. Za načrte v polnem merilu uporabite Dejansko velikost. Reload documents when their files change on disk Znova naloži dokumente, ko se njihove datoteke na disku spremenijo + Don't show again Ne prikaži več + Show a notice when a document is secured Prikaži obvestilo, ko je dokument zaščiten Always use my default zoom and page layout Vedno uporabi moje privzeto povečanje in postavitev strani This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ta datoteka se je na disku spremenila. Ponovno naloži pokaže novo različico in zavrže neshranjene spremembe; Shrani jo zamenja s to različico. Reload Znova naloži diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index c93efee5a..8921a92a2 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2263,6 +2263,8 @@ key Space Hapësirë Printer paper and scaling may differ. Check driver settings. Letra dhe shkallëzimi i printerit mund të ndryshojnë. Kontrolloni cilësimet e drajverit. Drawing scale changes. Use Actual size for full-scale plans. Shkalla e vizatimit ndryshon. Përdorni Madhësinë aktuale për planet në shkallë të plotë. Reload documents when their files change on disk Ringarko dokumentet kur skedarët e tyre ndryshojnë në disk + Don't show again Mos e shfaq më + Show a notice when a document is secured Shfaq një njoftim kur një dokument është i siguruar This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Ky skedar ndryshoi në disk. Ringarkojeni për të parë versionin e ri; ndryshimet tuaja të paruajtura do të humbasin. Reload Ringarko Couldn't reload {name}: {e} Nuk u ringarkua {name}: {e} diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index fa3f6cec4..50366f74c 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2028,6 +2028,8 @@ action wizard Start ప్రారంభించు Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF ఏ లేఅవుట్‌ను గానీ జూమ్‌ను గానీ కోరనప్పుడు ఉపయోగించబడుతుంది. నిరంతర స్క్రోలింగ్ పేజీల మధ్య ఎప్పుడూ స్నాప్ అవ్వదు. Reopen the files that were open when PdfCraft last closed PdfCraftను చివరిసారి మూసివేసినప్పుడు తెరిచి ఉన్న ఫైల్‌లను మళ్లీ తెరువు Reload documents when their files change on disk డిస్క్‌లో వాటి ఫైల్‌లు మారినప్పుడు పత్రాలను మళ్లీ లోడ్ చేయి + Don't show again మళ్లీ చూపవద్దు + Show a notice when a document is secured పత్రం రక్షించబడినప్పుడు నోటీసు చూపు Always use my default zoom and page layout ఎల్లప్పుడూ నా డిఫాల్ట్ జూమ్ మరియు పేజీ లేఅవుట్‌ను ఉపయోగించు This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. ఈ ఫైల్ డిస్క్‌లో మారింది. రీలోడ్ కొత్త వెర్షన్‌ను చూపించి సేవ్ చేయని మార్పులను వదిలేస్తుంది; సేవ్ దాని స్థానంలో ఈ వెర్షన్‌ను ఉంచుతుంది. Reload మళ్లీ లోడ్ చేయి diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index e56770757..c9f327b69 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2226,6 +2226,8 @@ signature pad Type พิมพ์ Printer paper and scaling may differ. Check driver settings. กระดาษและการปรับขนาดของเครื่องพิมพ์อาจต่างออกไป โปรดตรวจสอบการตั้งค่าไดรเวอร์ Drawing scale changes. Use Actual size for full-scale plans. มาตราส่วนของแบบจะเปลี่ยน ใช้ขนาดจริงสำหรับแบบแปลนเต็มมาตราส่วน Reload documents when their files change on disk โหลดเอกสารใหม่เมื่อไฟล์บนดิสก์เปลี่ยนแปลง + Don't show again ไม่ต้องแสดงอีก + Show a notice when a document is secured แสดงการแจ้งเตือนเมื่อเอกสารมีการรักษาความปลอดภัย This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. ไฟล์นี้เปลี่ยนแปลงบนดิสก์ โหลดใหม่เพื่อดูรุ่นใหม่ การเปลี่ยนแปลงที่ยังไม่ได้บันทึกจะหายไป Reload โหลดใหม่ Couldn't reload {name}: {e} โหลด {name} ใหม่ไม่ได้: {e} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c292b8863..815873a25 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2316,6 +2316,8 @@ key Space Boşluk Printer paper and scaling may differ. Check driver settings. Yazıcı kâğıdı ve ölçekleme farklı olabilir. Sürücü ayarlarını kontrol edin. Drawing scale changes. Use Actual size for full-scale plans. Çizim ölçeği değişir. Tam ölçekli planlar için Gerçek boyutu kullanın. Reload documents when their files change on disk Diskteki dosyaları değiştiğinde belgeleri yeniden yükle + Don't show again Bir daha gösterme + Show a notice when a document is secured Bir belge güvenli olduğunda bildirim göster Always use my default zoom and page layout Her zaman varsayılan yakınlaştırmamı ve sayfa düzenimi kullan This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Bu dosya diskte değişti. Yeniden yükle yeni sürümü gösterir ve kaydedilmemiş değişikliklerinizi atar; Kaydet onu bu sürümle değiştirir. Reload Yeniden yükle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cc8f45765..90c5ceb4a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2341,6 +2341,8 @@ key Space Пробіл Printer paper and scaling may differ. Check driver settings. Папір і масштаб принтера можуть відрізнятися. Перевірте налаштування драйвера. Drawing scale changes. Use Actual size for full-scale plans. Масштаб креслення змінюється. Для друку в початковому масштабі виберіть Фактичний розмір. Reload documents when their files change on disk Перезавантажувати документи, коли їхні файли змінюються на диску + Don't show again Більше не показувати + Show a notice when a document is secured Показувати сповіщення, коли документ захищено Always use my default zoom and page layout Завжди використовувати мій типовий масштаб і макет сторінок This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Цей файл змінено на диску. «Перезавантажити» покаже нову версію й відкине незбережені зміни; «Зберегти» замінить її цією версією. Reload Перезавантажити diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 87abe5129..c0786df07 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -969,6 +969,8 @@ theme Appearance Giao diện Redaction code: Mã xóa nội dung nhạy cảm: Reload Tải lại Reload documents when their files change on disk Tải lại tài liệu khi tệp của chúng thay đổi trên đĩa + Don't show again Không hiển thị lại + Show a notice when a document is secured Hiển thị thông báo khi tài liệu được bảo mật Always use my default zoom and page layout Luôn dùng thu phóng và bố cục trang mặc định của tôi Remove checkmark Bỏ dấu kiểm Remove invalid links and bookmarks Gỡ các liên kết và dấu trang không hợp lệ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 594f68df8..b13af1286 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2143,6 +2143,8 @@ Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF 未指定布局或缩放时使用。连续滚动时页面不会逐页吸附。 Reopen the files that were open when PdfCraft last closed 重新打开上次关闭 PdfCraft 时打开的文件 Reload documents when their files change on disk 文件在磁盘上更改时重新加载对应的文档 + Don't show again 不再显示 + Show a notice when a document is secured 文档已加密时显示通知 Always use my default zoom and page layout 始终使用我的默认缩放和页面布局 This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. 此文件已在磁盘上更改。“重新加载”显示新版本并放弃未保存的更改;“保存”则用此版本替换它。 Reload 重新加载 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb578ba26..667f84217 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2193,6 +2193,8 @@ key Space 空格 Tools 工具 Inspector 檢查器 Reload documents when their files change on disk 檔案在磁碟上變更時重新載入對應的文件 + Don't show again 不再顯示 + Show a notice when a document is secured 文件已加密時顯示通知 Always use my default zoom and page layout 一律使用我的預設縮放與頁面佈局 This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. 此檔案已在磁碟上變更。「重新載入」會顯示新版本並捨棄未儲存的變更;「儲存」則會以此版本取代它。 Reload 重新載入 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 7bc3dd044..dadd9c29d 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -394,6 +394,8 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok // The web build has no files on disk to watch. #[cfg(not(target_arch = "wasm32"))] ui.add(widgets::checkbox(&mut app.reload_changed_files, tl!("Reload documents when their files change on disk"))); + // The notice's "Don't show again" turns this off (#4035); the restrictions stay either way. + ui.add(widgets::checkbox(&mut app.show_secured_notice, tl!("Show a notice when a document is secured"))); ui.horizontal(|ui| { ui.label(tl!("Scroll speed (mouse wheel and arrow keys)")); for speed in crate::scroll_speed::ScrollSpeed::ALL { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64b22ff18..f2dbabe19 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -589,6 +589,9 @@ pub struct PdfCraftApp { quit_session: Option, /// Preferences: reload a document when its file changes on disk (#431). pub reload_changed_files: bool, + /// Preferences: tell the user, above the pages, that a document is secured (its security + /// settings restrict changes). Off only hides that notice (#4035); kept in the settings. + pub show_secured_notice: bool, /// Preferences: open every document at Default zoom and Default page display, whatever /// layout or zoom it asks for (#1618). pub always_default_view: bool, @@ -986,6 +989,7 @@ impl PdfCraftApp { last_session: Default::default(), quit_session: None, reload_changed_files: true, + show_secured_notice: true, always_default_view: false, #[cfg(not(target_arch = "wasm32"))] watch: Default::default(), @@ -2426,6 +2430,7 @@ impl PdfCraftApp { v["redact_prefs"] = self.redact_defaults.to_json(); // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); + v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); v["organize_zoom"] = serde_json::json!(self.organize_zoom); // Canonical names survive changes to the order of PAPERS and do not depend on UI language. v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)); @@ -2471,6 +2476,9 @@ impl PdfCraftApp { if let Some(on) = v["reload_changed_files"].as_bool() { self.reload_changed_files = on; } + if let Some(on) = v["show_secured_notice"].as_bool() { + self.show_secured_notice = on; + } self.pinned.restore(&v["pinned_folders"]); if let Ok(preference) = serde_json::from_value::(v["theme"].clone()) { self.set_theme_preference(preference); @@ -2899,6 +2907,14 @@ impl PdfCraftApp { }; } ("ui-scale", _) => self.ui_scale = ui_scale::parse(value)?, + // `--secured-notice false`: no notice above the pages that a document is secured. + ("secured-notice", _) => { + self.show_secured_notice = match value { + "true" => true, + "false" => false, + _ => return Err("secured-notice must be true or false".into()), + }; + } ("system-title-bar", _) => { self.system_title_bar = match value { "true" => true, diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 71ba9ccda..0671a8f83 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -2348,6 +2348,61 @@ fn the_security_notice_unlocks_with_the_permissions_password() { assert!(!h.state().session.get(h.state().active_ids().unwrap().1).unwrap().dirty, "unlocking is not an edit"); } +/// #4035: the secured-document notice can be turned off for good. "Don't show again" hides it +/// in every document, the choice is kept in the settings and turned back on in Preferences (or +/// `--secured-notice`); the document stays restricted and Properties ▸ Security still opens. +#[test] +fn the_security_notice_can_be_turned_off_for_good() { + let open = |settings: Option| { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + if let Some(s) = &settings { + app.restore(s); + } + app.set_option("language", "en").unwrap(); + app.open_bytes("locked.pdf", None, protected("", "owner", 0b0100)).unwrap(); + app + }); + h.run_steps(4); + h + }; + let mut h = open(None); + assert!(h.state().show_secured_notice, "on by default"); + h.get_by_label_contains("This document is secured"); + h.get_by_label("Don't show again").click(); + h.run_steps(2); + assert!(h.query_by_label_contains("This document is secured").is_none()); + assert!(!h.state().show_secured_notice); + let id = h.state().active_ids().unwrap().1; + assert!(h.state().session.get(id).unwrap().security_summary().is_some_and(|s| !s.owner), "still restricted"); + h.state_mut().set_option("dialog", "properties").unwrap(); + h.run_steps(2); + h.get_by_label("Security").click(); + h.run_steps(3); + h.get_by_label("Password security"); + h.get_by_label("User password"); + // Kept in the settings: a later session opens the same file without the notice. + let saved = h.state().persist(); + let mut h = open(Some(saved)); + assert!(h.query_by_label_contains("This document is secured").is_none(), "remembered across a restart"); + // Back on from Preferences. + h.state_mut().set_option("dialog", "preferences").unwrap(); + h.run_steps(2); + h.get_by_role_and_label(Role::CheckBox, "Show a notice when a document is secured").scroll_to_me(); + h.run_steps(4); + h.get_by_role_and_label(Role::CheckBox, "Show a notice when a document is secured").click(); + h.run_steps(2); + assert!(h.state().show_secured_notice); + h.state_mut().dialog = None; + h.run_steps(2); + h.get_by_label_contains("This document is secured"); + // Scriptable, with a clear refusal for anything else. + let app = h.state_mut(); + app.set_option("secured-notice", "false").unwrap(); + assert!(!app.show_secured_notice); + assert_eq!(app.set_option("secured-notice", "maybe"), Err("secured-notice must be true or false".into())); +} + /// Changing a restricted document's security asks for its permissions password first (Acrobat /// does too), then carries on. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 45dda3982..83b038c3a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1802,8 +1802,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/it/xfa.rs::a_secured_dynamic_xfa_form_shows_its_layout_and_its_security"] -notes = "On a secured form (fields or XFA), the security sentence follows the form's notice, which keeps Highlight fields and the XFA layout notes." +evidence = ["crates/ui-egui/tests/it/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/it/xfa.rs::a_secured_dynamic_xfa_form_shows_its_layout_and_its_security", "crates/ui-egui/tests/it/editing.rs::the_security_notice_can_be_turned_off_for_good"] +notes = "On a secured form (fields or XFA), the security sentence follows the form's notice, which keeps Highlight fields and the XFA layout notes. The notice's Don't show again turns it off in every document (#4035), kept in the settings and turned back on in Preferences (--secured-notice); the restrictions and Document Properties > Security are unchanged." [[feature]] id = "view.pdfa-view-mode" From cf730534f0ec2967edb0222b5d8dfdce9516603a Mon Sep 17 00:00:00 2001 From: Lombardi2003 Date: Thu, 22 Oct 2026 18:13:59 +0200 Subject: [PATCH 075/254] fix(ui): "Don't show again" only on the bar that shows the security notice (#4035) From review of #4096: when a secured form's scripts were turned off, the bar shows that message instead of the security one, but still offered "Don't show again", which would turn off a notice the user had not seen. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 45265fe9d..d06efd0ed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4920,7 +4920,8 @@ fn notices( if secured && doc.can_unlock() && crate::widgets::pill_button(ui, tl!("Unlock…"), true).clicked() { unlock = true; } - if secured && crate::widgets::pill_button(ui, tl!("Don't show again"), false).clicked() { + // Only when the bar shows the security message: a form's stopped scripts come first. + if secured && !scripts_off && crate::widgets::pill_button(ui, tl!("Don't show again"), false).clicked() { hide_secured = true; } if repaired && !secured && info.fields.is_empty() && crate::widgets::pill_button(ui, tl!("Details"), false).clicked() { From 6996aac44ac093e8aab0f8e5e38043b7ae8fcdb0 Mon Sep 17 00:00:00 2001 From: magdale76 <16279778+magdale76@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:15:37 +0200 Subject: [PATCH 076/254] M3: Document Properties shows a title and author stated only in XMP (#2373) Description read Title, Author, Subject and Keywords only from /Info. A file that states them only in its XMP packet (/Info is deprecated in PDF 2.0, and some writers leave it out) showed empty fields, while Acrobat shows them. organize::info now falls back to the packet's dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool and pdf:Producer: a language alternative gives its x-default item, and several authors are joined with "; ". The engine fills the inspection's missing fields the same way, so the read-only Description rows and doc_info agree with the editable fields. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 37 +++++++--- crates/engine/src/tests.rs | 24 +++++++ crates/organize/src/lib.rs | 12 +++- crates/organize/src/tests.rs | 37 ++++++++++ crates/organize/src/xmp.rs | 130 +++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 6 files changed, 229 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f257865d3..e3e077cee 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -946,6 +946,20 @@ fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) && a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation) } +/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read +/// the way the full inspection reads them: NULs and spaces trimmed, empty is none. +fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { + let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); + [ + (&mut info.title, read("Title")), + (&mut info.author, read("Author")), + (&mut info.subject, read("Subject")), + (&mut info.keywords, read("Keywords")), + (&mut info.creator, read("Creator")), + (&mut info.producer, read("Producer")), + ] +} + /// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for /// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page /// images agree with the app and independent viewers. It never changes the source document, and an @@ -3666,7 +3680,7 @@ impl Session { name: String, path: Option, bytes: Arc>, - info: DocInfo, + mut info: DocInfo, cos: Result, Box>, render_password: Option, password: Option<&str>, @@ -3698,6 +3712,15 @@ impl Session { }; let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); + // The inspection reads only `/Info`; a file stating its title or author only in XMP still + // shows them (#2373). + if let Some(e) = &editor { + for (field, value) in info_fields(&mut info, &e.cos) { + if field.is_none() { + *field = value; + } + } + } let display = display_bytes(editor.as_mut(), &bytes); let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); let (form, adopted) = editor @@ -4063,15 +4086,9 @@ impl Session { editor.cos.bytes().clone() }; if scope == Scope::Metadata { - // Read the way the full inspection reads them (NULs and spaces trimmed, empty is - // none), so editing one entry doesn't change how the others display. - let info = |key| pdfcraft_organize::info(&editor.cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); - doc.info.title = info("Title"); - doc.info.author = info("Author"); - doc.info.subject = info("Subject"); - doc.info.keywords = info("Keywords"); - doc.info.creator = info("Creator"); - doc.info.producer = info("Producer"); + for (field, value) in info_fields(&mut doc.info, &editor.cos) { + *field = value; + } doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); doc.info.file_size = bytes.len(); #[cfg(test)] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3012a1f3f..1e2485c66 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -425,6 +425,30 @@ impl RenderRequestFor { } } +/// A file with its title and author only in XMP shows them in the document information and in +/// Document Properties ▸ Description (#2373). +#[test] +fn document_information_stated_only_in_xmp_is_shown() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + let xmp = "\ + \ + Quarterly report\ + Ada"; + let mut dict = pdfcraft_cos::Dict::new(); + dict.set(b"Type".to_vec(), pdfcraft_cos::Object::name("Metadata")); + dict.set(b"Subtype".to_vec(), pdfcraft_cos::Object::name("XML")); + let m = cos.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(dict, xmp.as_bytes().to_vec()))); + let root = cos.root().unwrap(); + cos.update_dict(root, |c| c.set(b"Metadata".to_vec(), pdfcraft_cos::Object::Ref(m))).unwrap(); + let bytes = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let mut s = Session::new(); + let id = s.open("xmp.pdf", None, Arc::new(bytes), None).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!((doc.info.title.as_deref(), doc.info.author.as_deref(), doc.info.subject.as_deref()), (Some("Quarterly report"), Some("Ada"), None)); + assert_eq!(doc.info_value("Title").as_deref(), Some("Quarterly report")); + assert_eq!(doc.info_value("Author").as_deref(), Some("Ada")); +} + #[test] fn edits_update_view_data_and_mark_dirty() { let (mut s, id) = session_with(3); diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 6a696dd4d..9d37de0f6 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -658,10 +658,16 @@ pub fn check_custom_key(name: &str) -> Result<(), OrganizeError> { Ok(()) } -/// Read a document-information entry as text. +/// Read a document-information entry as text: `/Info`, else the XMP packet (Title, Author, +/// Subject, Keywords, Creator and Producer, see `xmp`). pub fn info(doc: &Document, key: &str) -> Option { - let info = doc.trailer().get(b"Info").map(|o| doc.resolve(o))?; - info.as_dict()?.get(key.as_bytes()).and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text())) + let in_info = doc + .trailer() + .get(b"Info") + .map(|o| doc.resolve(o)) + .and_then(|info| info.as_dict()?.get(key.as_bytes()).and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text()))); + // Files that state it only in their XMP packet (#2373). + in_info.filter(|v| !v.trim_matches('\0').trim().is_empty()).or_else(|| xmp::info_value(doc, key)) } /// Set (or clear, with an empty value) a document-information entry. diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 7a80e38e4..89eeb4357 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -628,6 +628,43 @@ fn document_info_edits_update_the_xmp_packet() { assert_eq!((dict.name(b"Type"), dict.name(b"Subtype"), dict.get(b"Filter")), (Some(&b"Metadata"[..]), Some(&b"XML"[..]), None)); } +/// A file that states its title, author and so on only in XMP (PDF 2.0 deprecates `/Info`) shows +/// them in Document Properties (#2373); `/Info` wins where it has a value. +#[test] +fn document_info_falls_back_to_the_xmp_packet() { + let xmp = LIBREOFFICE_XMP + .replace( + "Untitled 1", + "BerichtReport & “notes”", + ) + .replace("Old Author", "Ada\nGrace") + .replace("Writer 1.0", "Writer 1.0"); + let mut doc = doc_with_xmp(xmp.as_bytes()); + // Both: /Info's own values, XMP for what /Info lacks. + assert_eq!(info(&doc, "Title").as_deref(), Some("Untitled 1")); + assert_eq!(info(&doc, "Subject").as_deref(), Some("Old subject")); + // XMP only. + doc.trailer_mut().remove(b"Info"); + let doc = full_roundtrip(&doc); + assert_eq!(info(&doc, "Title").as_deref(), Some("Report & \u{201C}notes\u{201D}")); + assert_eq!(info(&doc, "Author").as_deref(), Some("Ada; Grace")); + assert_eq!(info(&doc, "Subject").as_deref(), Some("Old subject")); + assert_eq!(info(&doc, "Producer").as_deref(), Some("Writer 1.0")); + assert_eq!(info(&doc, "Keywords"), None, "an empty property is no value"); + assert_eq!(info(&doc, "Rating"), None, "no XMP counterpart"); + // The attribute form, and a packet that isn't UTF-8 is not read. + let attrs = "\ + "; + let mut doc = doc_with_xmp(attrs.as_bytes()); + doc.trailer_mut().remove(b"Info"); + assert_eq!(info(&doc, "Keywords").as_deref(), Some("a, b & c")); + let mut latin1 = LIBREOFFICE_XMP.as_bytes().to_vec(); + latin1.extend_from_slice(b""); + let mut doc = doc_with_xmp(&latin1); + doc.trailer_mut().remove(b"Info"); + assert_eq!(info(&doc, "Subject"), None); +} + #[test] fn document_info_edits_leave_files_without_usable_xmp_alone() { // No packet: none is added. diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs index bbcbbcf83..21cb282c2 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -92,6 +92,120 @@ fn edit_packet(doc: &mut Document, edit: impl FnOnce(&str) -> Option) -> Ok(true) } +/// The value the catalog's XMP packet gives the document information key `key`, for files that +/// state their title, author and so on only there (`/Info` is deprecated in PDF 2.0, and some +/// writers leave it out). A language alternative gives its `x-default` item (else the first), +/// several authors are joined with "; ". `None` for keys XMP doesn't mirror, files without XMP, +/// packets that can't be read safely, and empty values. +pub(crate) fn info_value(doc: &Document, key: &str) -> Option { + let (name, _, shape) = property(key)?; + let root = doc.root()?; + let r = doc.get(root).as_dict()?.reference(b"Metadata")?; + let obj = doc.get(r); + let Object::Stream(stream) = &*obj else { return None }; + let xmp = String::from_utf8(stream.decoded_within(MAX_XMP).ok()?).ok()?; + get_property(&xmp, name, shape) +} + +/// The text of the property `name` in `xmp`, element or attribute form (see [`info_value`]). +fn get_property(xmp: &str, name: &str, shape: Shape) -> Option { + let value = if let Some((start, end)) = element_span(xmp, name) { + let element = xmp.get(start..end)?; + let open_end = element.find('>')?; + // ``: no value. + if element.get(..open_end)?.ends_with('/') { + return None; + } + let inner = element.get(open_end + 1..)?.strip_suffix(&format!(""))?; + let items = list_items(inner); + if items.is_empty() { + unescape(inner.trim()) + } else if shape == Shape::LangAlt { + let (_, text) = items.iter().find(|(lang, _)| lang.as_deref() == Some("x-default")).or(items.first())?; + unescape(text.trim()) + } else { + items.iter().map(|(_, text)| unescape(text.trim())).filter(|t| !t.is_empty()).collect::>().join("; ") + } + } else { + let (_, value_start, value_end, _) = attribute_span(xmp, name)?; + unescape(xmp.get(value_start..value_end)?.trim()) + }; + Some(value).filter(|v| !v.trim().is_empty()) +} + +/// The `rdf:li` items of a property's content: each one's `xml:lang` (if any) and its text. +fn list_items(inner: &str) -> Vec<(Option, &str)> { + let mut items = Vec::new(); + let mut rest = inner; + while let Some(i) = rest.find("' || c == '/' || c.is_whitespace()) { + rest = after; + continue; + } + let Some(tag_end) = after.find('>') else { break }; + let tag = after.get(..tag_end).unwrap_or_default(); + let lang = tag.find("xml:lang=").and_then(|at| { + let v = tag.get(at + "xml:lang=".len()..)?; + let quote = v.chars().next().filter(|c| matches!(c, '"' | '\''))?; + let v = v.get(1..)?; + Some(v.get(..v.find(quote)?)?.to_string()) + }); + let Some(body) = after.get(tag_end + 1..) else { break }; + if tag.ends_with('/') { + items.push((lang, "")); + rest = body; + continue; + } + let Some(close) = body.find("") else { break }; + items.push((lang, body.get(..close).unwrap_or_default())); + rest = body.get(close..).unwrap_or_default(); + } + items +} + +/// XML text with its character and entity references resolved (an unknown one is kept as is). +fn unescape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(i) = rest.find('&') { + out.push_str(rest.get(..i).unwrap_or_default()); + let after = rest.get(i..).unwrap_or_default(); + let Some(semi) = after.find(';').filter(|&n| n <= 12) else { + out.push('&'); + rest = after.get(1..).unwrap_or_default(); + continue; + }; + let entity = after.get(1..semi).unwrap_or_default(); + let c = match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => entity + .strip_prefix("#x") + .or_else(|| entity.strip_prefix("#X")) + .and_then(|h| u32::from_str_radix(h, 16).ok()) + .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok())) + .and_then(char::from_u32), + }; + match c { + Some(c) => { + out.push(c); + rest = after.get(semi + 1..).unwrap_or_default(); + } + None => { + out.push('&'); + rest = after.get(1..).unwrap_or_default(); + } + } + } + out.push_str(rest); + out +} + /// `xmp` with the property `name` (namespace `ns`) set to `value`, or removed when `value` is /// empty; `None` when nothing changes or the packet has no `rdf:RDF` to add the property to. fn set_property(xmp: &str, name: &str, ns: &str, shape: Shape, value: &str) -> Option { @@ -206,6 +320,22 @@ mod tests { Old subject\n\ \n \n\n"; + #[test] + fn values_are_read_back_unescaped() { + assert_eq!(unescape("a & b <c> AB &bogus; � & end"), "a & b AB &bogus; � & end"); + assert_eq!(get_property(PACKET, "dc:title", Shape::LangAlt).as_deref(), Some("Untitled 1")); + assert_eq!(get_property(PACKET, "dc:creator", Shape::Seq).as_deref(), Some("Old Author")); + assert_eq!(get_property(PACKET, "pdf:Producer", Shape::Text).as_deref(), Some("Writer 1.0")); + assert_eq!(get_property(PACKET, "pdf:Keywords", Shape::Text), None); + // No x-default: the first language; empty and self-closed items are skipped. + let alt = "RapportReport"; + assert_eq!(get_property(alt, "dc:title", Shape::LangAlt).as_deref(), Some("Rapport")); + let seq = " Ada"; + assert_eq!(get_property(seq, "dc:creator", Shape::Seq).as_deref(), Some("Ada")); + // Unfinished markup reads as nothing rather than as a wrong value. + assert_eq!(get_property("Cut", "dc:title", Shape::LangAlt), None); + } + #[test] fn element_values_are_replaced_in_place() { let out = set_property(PACKET, "dc:title", DC, Shape::LangAlt, "Guide & ").unwrap(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 45dda3982..34386b512 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3448,8 +3448,8 @@ milestone = "M3" status = "shipped" commands = ["file.properties"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft"] -notes = "" +evidence = ["crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft", "crates/organize/src/tests.rs::document_info_falls_back_to_the_xmp_packet", "crates/engine/src/tests.rs::document_information_stated_only_in_xmp_is_shown"] +notes = "Title, Author, Subject and Keywords (and Creator, Producer in doc_info) come from /Info, else from the XMP packet (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) for files that state them only there; edits write both. Missing: a UTF-16 XMP packet isn't read, and /Info kept only in an older revision's trailer isn't looked up" [[feature]] id = "organize.edit-metadata" From 76f28a41e51e7577315cfeb64145b3b6fd4bb62b Mon Sep 17 00:00:00 2001 From: magdale76 <16279778+magdale76@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:36:35 +0200 Subject: [PATCH 077/254] M3: an empty XMP alternative or list reads as no value, not as its markup (#2373) `get_property` returned the raw child markup when a property had no `rdf:li` items, so `` with no /Info title showed "" in Document Properties. Character data that still holds a `<` is markup, never a value: an empty Alt/Seq/Bag, or an item holding a nested structure, now reads as none. Co-Authored-By: Claude Opus 5.5 --- crates/organize/src/xmp.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs index 21cb282c2..9ed9c3320 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -119,12 +119,13 @@ fn get_property(xmp: &str, name: &str, shape: Shape) -> Option { let inner = element.get(open_end + 1..)?.strip_suffix(&format!(""))?; let items = list_items(inner); if items.is_empty() { - unescape(inner.trim()) + // An empty `` or `` has no value, not its markup. + text_value(inner)? } else if shape == Shape::LangAlt { let (_, text) = items.iter().find(|(lang, _)| lang.as_deref() == Some("x-default")).or(items.first())?; - unescape(text.trim()) + text_value(text)? } else { - items.iter().map(|(_, text)| unescape(text.trim())).filter(|t| !t.is_empty()).collect::>().join("; ") + items.iter().filter_map(|(_, text)| text_value(text)).filter(|t| !t.is_empty()).collect::>().join("; ") } } else { let (_, value_start, value_end, _) = attribute_span(xmp, name)?; @@ -133,6 +134,13 @@ fn get_property(xmp: &str, name: &str, shape: Shape) -> Option { Some(value).filter(|v| !v.trim().is_empty()) } +/// Character data `text` as a value, unescaped; `None` when it still holds markup (a `<`, which +/// a value escapes as `<`), so a structure the reader doesn't take apart never shows as text. +fn text_value(text: &str) -> Option { + let text = text.trim(); + (!text.contains('<')).then(|| unescape(text)) +} + /// The `rdf:li` items of a property's content: each one's `xml:lang` (if any) and its text. fn list_items(inner: &str) -> Vec<(Option, &str)> { let mut items = Vec::new(); @@ -334,6 +342,12 @@ mod tests { assert_eq!(get_property(seq, "dc:creator", Shape::Seq).as_deref(), Some("Ada")); // Unfinished markup reads as nothing rather than as a wrong value. assert_eq!(get_property("Cut", "dc:title", Shape::LangAlt), None); + // An empty alternative or list, or an item holding markup, has no value: never the markup. + assert_eq!(get_property("", "dc:title", Shape::LangAlt), None); + assert_eq!(get_property("", "dc:title", Shape::LangAlt), None); + assert_eq!(get_property("", "dc:creator", Shape::Seq), None); + let nested = "Ada"; + assert_eq!(get_property(nested, "dc:creator", Shape::Seq).as_deref(), Some("Ada")); } #[test] From 00a7d5b8879d39d748c20532303588fe5254b947 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:49:26 -0400 Subject: [PATCH 078/254] M4: a save prompt that opens in the background asks for the user's attention Quitting from the Dock menu (or logging out, or an agent's ui.quit) while another app is in front opens "Save changes?" in a window the user can't see, so it looked as if PdfCraft hadn't quit. The prompt now sends a critical user-attention request on the frame it opens while the window isn't focused: the Dock icon bounces until PdfCraft is active, the Windows taskbar button flashes until the window is in front, and X11 sets the urgency hint. Coming back, or answering, sends Reset. This is what AppKit does by itself when an inactive app presents a modal panel; PdfCraft's prompt is drawn by egui, so AppKit never sees it. --- crates/ui-egui/src/editing.rs | 22 +++++++++++++++ crates/ui-egui/src/lib.rs | 11 ++++++++ crates/ui-egui/tests/it/control.rs | 35 ++++++++++++++++++++++++ crates/ui-egui/tests/it/editing.rs | 44 ++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 5 files changed, 114 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 5b821b638..7b3c1c765 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -960,6 +960,28 @@ impl PdfCraftApp { self.shutdown_recovery(); } } + + /// Ask for the user's attention when the "save changes?" question opens while PdfCraft is in + /// the background (Quit in the Dock menu, logging out, or an agent quitting), and withdraw the + /// request once the user comes back or the question is answered. + /// + /// The question holds up the quit the user asked for, so the request is critical, as AppKit + /// makes it for a modal panel that an inactive app presents: the Dock icon bounces until + /// PdfCraft is active, Windows flashes the taskbar button until the window is in front, and + /// X11 sets the urgency hint, which stays until it is reset. A question that opens in front + /// asks nothing, and neither does leaving it open: the user has seen it. + pub(crate) fn attend_save_prompt(&mut self, ctx: &egui::Context) { + let open = self.close_request.is_some(); + let focused = ctx.input(|i| i.focused); + if open && !self.save_prompt_was_open && !focused { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Critical)); + self.save_prompt_attention = true; + } else if self.save_prompt_attention && (focused || !open) { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Reset)); + self.save_prompt_attention = false; + } + self.save_prompt_was_open = open; + } } /// The single page a content edit changes (`None` for other edits). Like comment edits, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index af9912f45..0db6e1fef 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -642,6 +642,11 @@ pub struct PdfCraftApp { pub close_request: Option, /// The button of the "save changes?" question the keyboard has chosen; Save when it opens. pub(crate) save_prompt_button: dialogs::PromptButton, + /// Whether the "save changes?" question was pending last frame, to tell the frame it opens. + save_prompt_was_open: bool, + /// The "save changes?" question asked for the user's attention and the request still stands + /// (see `attend_save_prompt`). + save_prompt_attention: bool, /// A save waiting behind [`Dialog::OverwriteChanged`] (the web build has no files to check). #[cfg(not(target_arch = "wasm32"))] pub pending_overwrite: Option, @@ -1018,6 +1023,8 @@ impl PdfCraftApp { host_dirty: None, close_request: None, save_prompt_button: Default::default(), + save_prompt_was_open: false, + save_prompt_attention: false, #[cfg(not(target_arch = "wasm32"))] pending_overwrite: None, save_override: None, @@ -3146,6 +3153,8 @@ impl eframe::App for PdfCraftApp { storage.set_string("pdfcraft", self.persist()); } + /// Each frame's work before drawing: opens, operating-system and control-channel requests, + /// the quit guard, shortcuts, and the background jobs' results. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.ctx = Some(ctx.clone()); #[cfg(not(target_arch = "wasm32"))] @@ -3312,6 +3321,8 @@ impl eframe::App for PdfCraftApp { self.process_file_requests(); #[cfg(not(target_arch = "wasm32"))] self.process_picked(); + // Last, after everything that can open or answer the "save changes?" question. + self.attend_save_prompt(ctx); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 54bfe2fa3..dc62fa2a7 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -254,6 +254,41 @@ fn control_quit_preserves_dirty_documents_and_cancel_keeps_them_open() { assert!(!close_requested(&h)); } +/// Run one frame and return the attention requests it sent to the window. +fn step_attention(h: &mut Harness<'static, PdfCraftApp>) -> Vec { + h.step(); + let commands = h.output().viewport_output.values().flat_map(|v| v.commands.iter()); + commands.filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None }).collect() +} + +/// Send a request, run frames until it is answered and three more, and collect every attention +/// request those frames sent. +fn attention_during(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Vec { + let rx = c.send(method, params); + let mut sent = Vec::new(); + for _ in 0..30 { + sent.extend(step_attention(h)); + if rx.try_recv().is_ok() { + (0..3).for_each(|_| sent.extend(step_attention(h))); + return sent; + } + } + panic!("{method}: no reply after 30 frames"); +} + +/// An agent quits while another app is in front, then answers the prompt itself: the request is +/// withdrawn, so the taskbar or the window list stops flagging a window with nothing to ask. +#[test] +fn a_save_prompt_answered_in_the_background_withdraws_its_attention_request() { + use egui::UserAttentionType::{Critical, Reset}; + let (mut h, c) = harness(); + ok(&mut h, &c, "ui.command", json!({"id": "page.insert_blank"})); + h.input_mut().focused = false; + assert_eq!(attention_during(&mut h, &c, "ui.quit", json!({})), [Critical]); + assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Cancel"})), [Reset]); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["close_prompt"], false); +} + #[test] fn control_quit_clean_document_requests_normal_window_close() { for params in [json!({}), Value::Null] { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 71ba9ccda..83492dbf1 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -791,6 +791,50 @@ fn quitting_with_unsaved_changes_asks_for_each_document() { assert!(h.state().close_request.is_none()); } +/// Run `frames` frames and collect every attention request they sent to the window. +fn attention_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> Vec { + let mut sent = Vec::new(); + for _ in 0..frames { + h.step(); + let commands = h.output().viewport_output.values().flat_map(|v| v.commands.iter()); + sent.extend(commands.filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None })); + } + sent +} + +/// Close the window as the operating system does: Quit in the Dock menu, or logging out. +fn close_window(h: &mut Harness<'static, PdfCraftApp>) { + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); +} + +/// Quit from the Dock while another app is in front: the prompt opens in a window the user can't +/// see, so the Dock icon bounces (the taskbar button flashes) until they come back. A prompt that +/// opens in front asks for nothing. +#[test] +fn a_save_prompt_opened_in_the_background_asks_for_attention_until_the_user_returns() { + use egui::UserAttentionType::{Critical, Reset}; + let mut h = harness(2, |_| {}); + h.state_mut().views[0].select_pages(&[0]); + h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + h.input_mut().focused = false; + close_window(&mut h); + assert_eq!(attention_over(&mut h, 4), [Critical], "asked once, when the prompt opened"); + assert_eq!(h.state().close_request, Some(CloseRequest::Quit)); + h.input_mut().focused = true; + assert_eq!(attention_over(&mut h, 3), [Reset], "coming back withdraws the request"); + h.get_by_label_contains("Save changes to “doc.pdf”"); + h.get_by_label("Cancel").click(); + assert!(attention_over(&mut h, 3).is_empty()); + assert_eq!(h.state().views.len(), 1, "cancel keeps the document open"); + // With PdfCraft in front, the prompt is in plain sight. + close_window(&mut h); + assert!(attention_over(&mut h, 4).is_empty()); + h.get_by_label_contains("Save changes to “doc.pdf”"); + // Leaving with the prompt open doesn't ask either: the user has seen it. + h.input_mut().focused = false; + assert!(attention_over(&mut h, 3).is_empty()); +} + /// PDFs written to a fresh temp folder, removed when the test ends. struct TempPdfs(std::path::PathBuf); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 45dda3982..928272171 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -586,8 +586,8 @@ milestone = "M4" status = "shipped" commands = ["file.close"] tools = ["doc_close"] -evidence = ["crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/it/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_moves_between_buttons_with_the_keyboard", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_escape_still_cancels_whatever_is_chosen", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_starts_on_save_every_time_it_opens"] -notes = "Save is chosen when the prompt opens, so Enter saves. Left/Right (and Up/Down) and Tab/Shift+Tab move the choice between Don't save, Cancel and Save, shown with a focus ring; Enter or Space press the chosen button; Escape cancels. Alt+D, Alt+N and Ctrl/Cmd+D still answer Don't save. Not yet: the same keys on the other confirmations (Revert, reload, overwrite)." +evidence = ["crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/it/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_moves_between_buttons_with_the_keyboard", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_escape_still_cancels_whatever_is_chosen", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_starts_on_save_every_time_it_opens", "crates/ui-egui/tests/it/editing.rs::a_save_prompt_opened_in_the_background_asks_for_attention_until_the_user_returns", "crates/ui-egui/tests/it/control.rs::a_save_prompt_answered_in_the_background_withdraws_its_attention_request"] +notes = "Save is chosen when the prompt opens, so Enter saves. Left/Right (and Up/Down) and Tab/Shift+Tab move the choice between Don't save, Cancel and Save, shown with a focus ring; Enter or Space press the chosen button; Escape cancels. Alt+D, Alt+N and Ctrl/Cmd+D still answer Don't save. A prompt that opens while PdfCraft is in the background (Quit in the Dock menu, logging out, an agent's ui.quit) asks once for the user's attention, as a critical request: the Dock icon bounces until PdfCraft is active, the Windows taskbar button flashes until the window is in front, and X11 sets the urgency hint; coming back or answering withdraws it. Not yet: the same keys on the other confirmations (Revert, reload, overwrite); the other confirmations don't ask for attention; on macOS a prompt answered without coming back (over the control channel) leaves the Dock icon bouncing until PdfCraft is activated, because winit can't withdraw the request there." [[feature]] id = "core.dirty-indicator" From 95feaad2692e5f830ff09e739190333ef88624d9 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:04:12 -0400 Subject: [PATCH 079/254] M4: each document's save question asks for attention on its own Quitting with several unsaved documents moves from one question to the next without the prompt closing, so a question that opened in the background after one the user saw asked for nothing (review feedback on #4155). close_and_continue now marks the next document's question as new. A request that still stands carries over to it instead of being sent again. --- crates/ui-egui/src/editing.rs | 18 ++++++++++++------ crates/ui-egui/src/lib.rs | 7 ++++--- crates/ui-egui/tests/it/control.rs | 30 +++++++++++++++++++++++++++--- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 7b3c1c765..d9736c343 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -900,6 +900,8 @@ impl PdfCraftApp { /// Close tab `index` for a close request, then ask about the next dirty document, or quit. fn close_and_continue(&mut self, ctx: &egui::Context, index: usize, req: CloseRequest) { self.close_tab(index); + // Its question is answered: a question about the next document is a new one. + self.save_prompt_seen = false; // A newer prompt wins: the user may have started another close while a save was pending. if (req == CloseRequest::Quit || req == CloseRequest::All) && self.close_request.is_none() { match self.first_dirty() { @@ -969,18 +971,22 @@ impl PdfCraftApp { /// makes it for a modal panel that an inactive app presents: the Dock icon bounces until /// PdfCraft is active, Windows flashes the taskbar button until the window is in front, and /// X11 sets the urgency hint, which stays until it is reset. A question that opens in front - /// asks nothing, and neither does leaving it open: the user has seen it. + /// asks nothing, and neither does leaving it open: the user has seen it. Each document asked + /// about when quitting is a new question, so one that opens in the background asks even after + /// a question the user saw; a request that still stands carries over to it. pub(crate) fn attend_save_prompt(&mut self, ctx: &egui::Context) { let open = self.close_request.is_some(); let focused = ctx.input(|i| i.focused); - if open && !self.save_prompt_was_open && !focused { + if self.save_prompt_attention { + if focused || !open { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Reset)); + self.save_prompt_attention = false; + } + } else if open && !self.save_prompt_seen && !focused { ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Critical)); self.save_prompt_attention = true; - } else if self.save_prompt_attention && (focused || !open) { - ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Reset)); - self.save_prompt_attention = false; } - self.save_prompt_was_open = open; + self.save_prompt_seen = open; } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0db6e1fef..edfcb3b71 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -642,8 +642,9 @@ pub struct PdfCraftApp { pub close_request: Option, /// The button of the "save changes?" question the keyboard has chosen; Save when it opens. pub(crate) save_prompt_button: dialogs::PromptButton, - /// Whether the "save changes?" question was pending last frame, to tell the frame it opens. - save_prompt_was_open: bool, + /// `attend_save_prompt` has seen the pending "save changes?" question. It is false again once + /// the question is answered, so the next document's question is told apart from it. + save_prompt_seen: bool, /// The "save changes?" question asked for the user's attention and the request still stands /// (see `attend_save_prompt`). save_prompt_attention: bool, @@ -1023,7 +1024,7 @@ impl PdfCraftApp { host_dirty: None, close_request: None, save_prompt_button: Default::default(), - save_prompt_was_open: false, + save_prompt_seen: false, save_prompt_attention: false, #[cfg(not(target_arch = "wasm32"))] pending_overwrite: None, diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index dc62fa2a7..8bd7b5dd4 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -276,17 +276,41 @@ fn attention_during(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, me panic!("{method}: no reply after 30 frames"); } -/// An agent quits while another app is in front, then answers the prompt itself: the request is -/// withdrawn, so the taskbar or the window list stops flagging a window with nothing to ask. +/// Open `name` with an unsaved change, in a tab of its own. +fn open_unsaved(h: &mut Harness<'static, PdfCraftApp>, name: &str) { + let app = h.state_mut(); + app.open_bytes(name, None, fixture(1)).unwrap(); + app.active = Some(app.views.len() - 1); + app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); +} + +/// An agent quits while another app is in front, then answers the prompts itself. Quitting asks +/// about each unsaved document in turn; the request made for the first question stands for the +/// next, and answering the last withdraws it, so the taskbar or the window list stops flagging a +/// window with nothing to ask. Each question is a new one: when the first opened in front, the +/// next one asks when it opens in the background. #[test] fn a_save_prompt_answered_in_the_background_withdraws_its_attention_request() { use egui::UserAttentionType::{Critical, Reset}; let (mut h, c) = harness(); ok(&mut h, &c, "ui.command", json!({"id": "page.insert_blank"})); + open_unsaved(&mut h, "second.pdf"); h.input_mut().focused = false; assert_eq!(attention_during(&mut h, &c, "ui.quit", json!({})), [Critical]); + assert!(attention_during(&mut h, &c, "ui.click", json!({"label": "Don't save"})).is_empty(), "the request stands"); assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Cancel"})), [Reset]); - assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["close_prompt"], false); + + open_unsaved(&mut h, "third.pdf"); + h.input_mut().focused = true; + assert!(attention_during(&mut h, &c, "ui.quit", json!({})).is_empty(), "the first question opens in front"); + h.input_mut().focused = false; + assert!(attention_during(&mut h, &c, "ui.state", json!({})).is_empty(), "leaving it open asks nothing"); + assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Don't save"})), [Critical], "the next opens in the background"); + assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Cancel"})), [Reset]); + let state = ok(&mut h, &c, "ui.state", json!({})); + assert_eq!(state["close_prompt"], false); + let left: Vec<_> = state["documents"].as_array().unwrap().iter().map(|d| (d["name"].clone(), d["dirty"].clone())).collect(); + assert_eq!(left, [(json!("third.pdf"), json!(true))], "Don't save closed doc.pdf and second.pdf; Cancel kept third.pdf"); } #[test] From 04bc36a61076580a84ca200ae659730343190ed4 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:16:00 -0400 Subject: [PATCH 080/254] M4: a question the user saw stays seen when an earlier save finishes behind it --- crates/ui-egui/src/editing.rs | 53 ++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index d9736c343..ae6797d91 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -900,12 +900,15 @@ impl PdfCraftApp { /// Close tab `index` for a close request, then ask about the next dirty document, or quit. fn close_and_continue(&mut self, ctx: &egui::Context, index: usize, req: CloseRequest) { self.close_tab(index); - // Its question is answered: a question about the next document is a new one. - self.save_prompt_seen = false; - // A newer prompt wins: the user may have started another close while a save was pending. + // A newer prompt wins: the user may have started another close while a save was pending, + // and seen its question, which keeps counting as seen. if (req == CloseRequest::Quit || req == CloseRequest::All) && self.close_request.is_none() { match self.first_dirty() { - Some(_) => self.close_request = Some(req), + Some(_) => { + // A question about the next document is a new one. + self.save_prompt_seen = false; + self.close_request = Some(req); + } None if req == CloseRequest::Quit => self.quit(ctx), None => {} } @@ -1653,3 +1656,45 @@ mod content_page_tests { assert_eq!(content_page(&Edit::SetInfo { key: "Title".into(), value: "x".into() }), None); } } + +#[cfg(test)] +mod attention_tests { + use super::CloseRequest; + use crate::PdfCraftApp; + + /// The attention requests one frame of `attend_save_prompt` sends, with the window `focused` + /// or not. + fn attend(app: &mut PdfCraftApp, ctx: &egui::Context, focused: bool) -> Vec { + let mut out = ctx.run_ui(egui::RawInput { focused, ..Default::default() }, |ui| app.attend_save_prompt(ui.ctx())); + // The frame's font texture isn't uploaded anywhere in a unit test. + out.textures_delta.clear(); + out.viewport_output + .values() + .flat_map(|v| v.commands.iter()) + .filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None }) + .collect() + } + + /// A save that finishes after a newer "save changes?" question opened in front, and after the + /// user went to another app, asks for no attention: the user saw that question. + #[test] + fn a_save_finishing_behind_a_newer_question_the_user_saw_asks_for_nothing() { + let ctx = egui::Context::default(); + let mut app = PdfCraftApp::new(); + for name in ["a.pdf", "b.pdf", "c.pdf"] { + let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); + app.open_bytes(name, None, bytes.as_ref().clone()).unwrap(); + app.active = Some(app.views.len() - 1); + assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + } + // a.pdf was answered with Save while quitting. While that save is pending, the user closes + // c.pdf's tab, and its question opens in front. + let c = app.views[2].id; + app.close_request = Some(CloseRequest::Tab(c)); + assert!(attend(&mut app, &ctx, true).is_empty()); + // a.pdf's save finishes once the user has gone to another app. + app.close_and_continue(&ctx, 0, CloseRequest::Quit); + assert_eq!(app.close_request, Some(CloseRequest::Tab(c)), "the newer question stands"); + assert!(attend(&mut app, &ctx, false).is_empty(), "the user has seen it"); + } +} From 4118f479911f096cbf584d52d046196220e96579 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:17:12 +0800 Subject: [PATCH 081/254] M2: a symbolic TrueType font reads its (3, 0) cmap before its (1, 0) one (#4156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hayro-interpret's map_code tried a symbolic font's cmap subtables in the order the font lists them, by platform ID, so Macintosh (1, 0) came before Windows (3, 0). ISO 32000-2 §9.6.5.4 gives (3, 0) first and (1, 0) only otherwise. SAS 9.4 embeds AMT fonts whose (1, 0) table is wrong, so "8" drew as "p", "+" as "V", and "1" and "%" not at all. The Windows (3, 0)/(3, 1) records are now tried first; Macintosh and Unicode ones still serve codes those leave out. The test font is built in memory. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/raster.rs | 3 + .../raster/tests/symbolic_truetype_cmap.rs | 162 ++++++++++++++++++ vendor/README.md | 8 + vendor/hayro-interpret/src/font/true_type.rs | 48 ++++-- 4 files changed, 202 insertions(+), 19 deletions(-) create mode 100644 crates/render/src/raster/tests/symbolic_truetype_cmap.rs diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 1f8d2f19e..99aa92367 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -7439,6 +7439,9 @@ endstream endobj // ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48). mod icc_profile_cache; + // A symbolic TrueType font draws through its (3, 0) cmap subtable first (#4156). + mod symbolic_truetype_cmap; + /// Render `pdf`'s first page at 72 dpi with no time limit beyond the test's own. fn render_first_page(pdf: Vec) -> RenderedPage { let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); diff --git a/crates/render/src/raster/tests/symbolic_truetype_cmap.rs b/crates/render/src/raster/tests/symbolic_truetype_cmap.rs new file mode 100644 index 000000000..f66aff1da --- /dev/null +++ b/crates/render/src/raster/tests/symbolic_truetype_cmap.rs @@ -0,0 +1,162 @@ +//! A symbolic TrueType font draws through its (3, 0) `cmap` subtable before its (1, 0) one +//! (vendored hayro-interpret patch, #4156; ISO 32000-2 §9.6.5.4: "If the font contains a (3, 0) +//! subtable" it is used, "Otherwise, if the font contains a (1, 0) subtable"). SAS writes AMT +//! fonts whose (1, 0) table is wrong, so "8" drew as "p" and "+" as "V". +//! +//! The font is built here, in memory: three glyphs (`.notdef`, an empty one and a filled square), +//! a (1, 0) subtable mapping `A` to the empty glyph and a (3, 0) subtable mapping it (as U+F041) +//! to the square. No font file is involved. + +use super::render_first_page; + +fn be16(out: &mut Vec, n: u16) { + out.extend_from_slice(&n.to_be_bytes()); +} + +fn be32(out: &mut Vec, n: u32) { + out.extend_from_slice(&n.to_be_bytes()); +} + +/// A TrueType font whose (1, 0) and (3, 0) subtables disagree about the code `A`. +fn disagreeing_font() -> Vec { + // Glyph 2: one contour, the square 0..1000 on both axes (glyph 0 and 1 are empty). + let mut square = Vec::new(); + for n in [1i16, 0, 0, 1000, 1000] { + out16(&mut square, n); + } + be16(&mut square, 3); // the contour's last point + be16(&mut square, 0); // no instructions + square.extend([1u8; 4]); // four on-curve points, two-byte coordinates + for dx in [0i16, 1000, 0, -1000] { + out16(&mut square, dx); + } + for dy in [0i16, 0, 1000, 0] { + out16(&mut square, dy); + } + let glyf = square.clone(); + let mut loca = Vec::new(); + for offset in [0u16, 0, 0, (glyf.len() / 2) as u16] { + be16(&mut loca, offset); + } + + let mut head = vec![0; 54]; + head[0..4].copy_from_slice(&[0, 1, 0, 0]); + head[12..16].copy_from_slice(&0x5f0f_3cf5u32.to_be_bytes()); + head[18..20].copy_from_slice(&1000u16.to_be_bytes()); + head[40..42].copy_from_slice(&1000i16.to_be_bytes()); + head[42..44].copy_from_slice(&1000i16.to_be_bytes()); + let mut hhea = vec![0; 36]; + hhea[0..4].copy_from_slice(&[0, 1, 0, 0]); + hhea[4..6].copy_from_slice(&1000i16.to_be_bytes()); + hhea[34..36].copy_from_slice(&3u16.to_be_bytes()); + let mut hmtx = Vec::new(); + for _ in 0..3 { + be16(&mut hmtx, 1000); + be16(&mut hmtx, 0); + } + let mut maxp = vec![0, 1, 0, 0]; + be16(&mut maxp, 3); + maxp.resize(32, 0); + + // (1, 0), format 0: `A` → glyph 1 (empty), and `B`, which the (3, 0) table leaves out, → + // glyph 2. + let mut mac = Vec::new(); + be16(&mut mac, 0); + be16(&mut mac, 262); + be16(&mut mac, 0); + let mut ids = [0u8; 256]; + ids[usize::from(b'A')] = 1; + ids[usize::from(b'B')] = 2; + mac.extend(ids); + // (3, 0), format 4: U+F041 → glyph 2 (the square). + let mut win = Vec::new(); + be16(&mut win, 4); + be16(&mut win, 32); + be16(&mut win, 0); + for n in [4u16, 4, 1, 0] { + be16(&mut win, n); // segCountX2, searchRange, entrySelector, rangeShift + } + for n in [0xF041u16, 0xFFFF, 0, 0xF041, 0xFFFF, 2u16.wrapping_sub(0xF041), 1, 0, 0] { + be16(&mut win, n); // endCode ×2, pad, startCode ×2, idDelta ×2, idRangeOffset ×2 + } + let mut cmap = Vec::new(); + be16(&mut cmap, 0); + be16(&mut cmap, 2); + for (platform, encoding, offset) in [(1u16, 0u16, 20u32), (3, 0, 20 + mac.len() as u32)] { + be16(&mut cmap, platform); + be16(&mut cmap, encoding); + be32(&mut cmap, offset); + } + cmap.extend(mac); + cmap.extend(win); + + let tables = [(*b"cmap", cmap), (*b"glyf", glyf), (*b"head", head), (*b"hhea", hhea), (*b"hmtx", hmtx), (*b"loca", loca), (*b"maxp", maxp)]; + let mut out = vec![0, 1, 0, 0]; + be16(&mut out, tables.len() as u16); + for n in [64u16, 2, 48] { + be16(&mut out, n); + } + let mut payload = Vec::new(); + let mut offset = 12 + 16 * tables.len(); + for (tag, bytes) in tables { + out.extend(tag); + let mut padded = bytes.clone(); + padded.resize(bytes.len().div_ceil(4) * 4, 0); + let sum = padded.as_chunks::<4>().0.iter().fold(0u32, |s, b| s.wrapping_add(u32::from_be_bytes(*b))); + be32(&mut out, sum); + be32(&mut out, offset as u32); + be32(&mut out, bytes.len() as u32); + offset += padded.len(); + payload.extend(padded); + } + out.extend(payload); + out +} + +fn out16(out: &mut Vec, n: i16) { + out.extend_from_slice(&n.to_be_bytes()); +} + +/// A 40 × 40 pt page that shows `text` at 30 pt from (5, 5) in the font, marked symbolic and +/// with no `/Encoding`, as SAS writes it. +fn page_with_symbolic_font(font: &[u8], text: &str) -> Vec { + let content = format!("BT /F1 30 Tf 5 5 Td ({text}) Tj ET").into_bytes(); + let objects: Vec> = 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 40 40] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(), content.clone(), b"\nendstream".to_vec()].concat(), + b"<< /Type /Font /Subtype /TrueType /BaseFont /AAAAAA+Sym /FirstChar 65 /LastChar 66 /Widths [1000 1000] /FontDescriptor 6 0 R >>".to_vec(), + b"<< /Type /FontDescriptor /FontName /AAAAAA+Sym /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 /CapHeight 1000 /StemV 80 /FontFile2 7 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", font.len()).into_bytes(), font.to_vec(), b"\nendstream".to_vec()].concat(), + ]; + 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(format!("{} 0 obj\n", i + 1).into_bytes()); + pdf.extend(object); + pdf.extend(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes()); + for offset in offsets { + pdf.extend(format!("{offset:010} 00000 n \n").into_bytes()); + } + pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes()); + pdf +} + +#[test] +fn a_symbolic_truetype_font_draws_through_its_3_0_subtable_first() { + let font = disagreeing_font(); + // The square covers (5, 5)–(35, 35) in page space: row 20 from the top is inside it. + let square_drawn = |text: &str| { + let page = render_first_page(page_with_symbolic_font(&font, text)); + let at = |x: u32, y: u32| page.rgba.get(((y * page.width + x) * 4) as usize).copied().unwrap_or(255); + assert_eq!(at(2, 2), 255, "nothing is drawn outside the square"); + at(20, 20) < 128 + }; + assert!(square_drawn("A"), "the (3, 0) subtable's square, not the (1, 0) subtable's empty glyph"); + assert!(square_drawn("B"), "a code the (3, 0) subtable leaves out still reads through (1, 0)"); +} diff --git a/vendor/README.md b/vendor/README.md index d8518ca8f..98ef1b4c1 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -99,3 +99,11 @@ are unchanged. Renderer regressions `japanese_substitute_missing_spaces_draw_no_ and `japanese_substitute_spaces_keep_pdf_widths_and_unicode` use the synthetic MS-Mincho / UniJIS-UCS2-HW-H fixture with craft-fonts, including `/W` and `/DW` placement controls. + +Symbolic TrueType `cmap` order (#4156): in `hayro-interpret` `TrueTypeFont::map_code`, a +symbolic font (no `/Encoding`, or the Symbolic flag) looks a code up in its Windows (3, 0) or +(3, 1) subtables before its Macintosh and Unicode ones, as ISO 32000-2 §9.6.5.4 prescribes; the +(1, 0) subtable still serves codes the (3, x) ones leave out. The records were tried in the +order the font lists them, platform ID first, so a wrong (1, 0) table (SAS 9.4's AMT fonts) +drew "8" as "p" and "+" as "V". Covered by `raster::tests::symbolic_truetype_cmap`, whose font +is built in memory. diff --git a/vendor/hayro-interpret/src/font/true_type.rs b/vendor/hayro-interpret/src/font/true_type.rs index c65043996..25b3ebc4e 100644 --- a/vendor/hayro-interpret/src/font/true_type.rs +++ b/vendor/hayro-interpret/src/font/true_type.rs @@ -360,26 +360,36 @@ impl EmbeddedKind { } } } else if let Ok(cmap) = self.base_font.font_ref().cmap() { - for record in cmap.encoding_records() { - if record.platform_id() == PlatformId::Windows - && matches!(record.encoding_id(), 0 | 1) - { - if let Ok(subtable) = record.subtable(cmap.offset_data()) { - for offset in [0x0000_u32, 0xF000, 0xF100, 0xF200] { - glyph = glyph - .or_else(|| subtable.map_codepoint(code as u32 + offset)) - .filter(|g| *g != GlyphId::NOTDEF); - } + // PdfCraft patch (#4156): the Windows (3, 0) subtable comes first, and the (1, 0) one + // only for codes it doesn't map (ISO 32000-2 §9.6.5.4: "If the font contains a (3, 0) + // subtable" it is used; "Otherwise, if the font contains a (1, 0) subtable"). The + // records are listed by platform ID, so Macintosh came before Windows and a wrong + // (1, 0) table, as SAS writes for its AMT fonts, drew "8" as "p" and "+" as "V". + for windows_pass in [true, false] { + for record in cmap.encoding_records() { + let windows = record.platform_id() == PlatformId::Windows + && matches!(record.encoding_id(), 0 | 1); + if windows != windows_pass { + continue; + } + if windows { + if let Ok(subtable) = record.subtable(cmap.offset_data()) { + for offset in [0x0000_u32, 0xF000, 0xF100, 0xF200] { + glyph = glyph + .or_else(|| subtable.map_codepoint(code as u32 + offset)) + .filter(|g| *g != GlyphId::NOTDEF); + } + } + } else if matches!( + record.platform_id(), + PlatformId::Macintosh | PlatformId::Unicode + ) && record.encoding_id() == 0 + && let Ok(subtable) = record.subtable(cmap.offset_data()) + { + glyph = glyph + .or_else(|| subtable.map_codepoint(code)) + .filter(|g| *g != GlyphId::NOTDEF); } - } else if matches!( - record.platform_id(), - PlatformId::Macintosh | PlatformId::Unicode - ) && record.encoding_id() == 0 - && let Ok(subtable) = record.subtable(cmap.offset_data()) - { - glyph = glyph - .or_else(|| subtable.map_codepoint(code)) - .filter(|g| *g != GlyphId::NOTDEF); } } } From be60c6d27fd67442f88e5bc8bff560880e4186da Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:25:55 +0800 Subject: [PATCH 082/254] M2: the vendor note names the function and the flag the code tests (#4156) Review on #4157 (CodeRabbit): the symbolic branch of EmbeddedKind::map_code is taken whenever the Nonsymbolic flag is not set; it doesn't look at /Encoding. Co-Authored-By: Claude Opus 5.5 --- vendor/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/vendor/README.md b/vendor/README.md index 98ef1b4c1..3ea194811 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -100,9 +100,10 @@ and `japanese_substitute_spaces_keep_pdf_widths_and_unicode` use the synthetic MS-Mincho / UniJIS-UCS2-HW-H fixture with craft-fonts, including `/W` and `/DW` placement controls. -Symbolic TrueType `cmap` order (#4156): in `hayro-interpret` `TrueTypeFont::map_code`, a -symbolic font (no `/Encoding`, or the Symbolic flag) looks a code up in its Windows (3, 0) or -(3, 1) subtables before its Macintosh and Unicode ones, as ISO 32000-2 §9.6.5.4 prescribes; the +Symbolic TrueType `cmap` order (#4156): in `hayro-interpret` `EmbeddedKind::map_code`, an +embedded TrueType font whose Nonsymbolic flag is not set (the branch `is_non_symbolic()` does not +take) looks a code up in its Windows (3, 0) or (3, 1) subtables before its Macintosh and Unicode +ones, as ISO 32000-2 §9.6.5.4 prescribes for symbolic fonts; the (1, 0) subtable still serves codes the (3, x) ones leave out. The records were tried in the order the font lists them, platform ID first, so a wrong (1, 0) table (SAS 9.4's AMT fonts) drew "8" as "p" and "+" as "V". Covered by `raster::tests::symbolic_truetype_cmap`, whose font From 5f042c691f3456cd7c601db782a3b5a54b861cfd Mon Sep 17 00:00:00 2001 From: Juan Jurado Date: Thu, 22 Oct 2026 16:45:39 -0600 Subject: [PATCH 083/254] M10: every paper size reaches the printer, from the dialog and doc_print (#3752) A CUPS job that names no paper gets the queue's PPD default (A4 on the reporter's Xerox queue), whatever the sheets were laid out on. Two gaps remained after #1583: - cups_media had no name for 13 of the 21 PAPERS sizes (A6, ARCH A-E1, ANSI C-E, A0-A2), so the Print dialog sent no media for them. They now use PWG 5101.1 names (the IANA-registered na_arch-e1_30x42in for ARCH E1), which carry their dimensions: CUPS feeds the driver's size of those dimensions whatever the driver calls it, and driverless IPP printers use them natively. Checked with cupsfilter against a synthetic PPD whose sizes use vendor keywords (ARCHD, AnsiD, A0...): each name produced its size's bounding box. The 8 sizes that already worked keep their PPD keywords. - doc_print to a printer (CLI run, MCP) sent no paper at all. It now resolves the paper to its PAPERS index and sends the same media option as the dialog, after the driver options. The job is built in spool_job so it can be tested without printing. print::media_option is the one place both get the option from. Both new tests were seen failing without the fix. The macOS tray half of #3752 (Auto Select instead of the PPD's default tray) is a craft-print change. --- ROADMAP.md | 1 + crates/automation/src/printing.rs | 111 ++++++++++++++++++++---------- crates/print/src/lib.rs | 50 ++++++++++---- crates/print/src/tests.rs | 28 ++++++++ crates/ui-egui/src/print_ui.rs | 2 +- parity/acrobat-features.toml | 4 +- 6 files changed, 145 insertions(+), 51 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 27c8e1b6b..ac34f9ae0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -190,6 +190,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-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 39cd45760..e43c3c6e9 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -26,6 +26,54 @@ fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) } +/// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS` +/// index and size; US Letter when none is given. +fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { + let mut papers = PAPERS.iter().enumerate(); + let found = match name { + None => papers.next(), + Some(p) => papers.find(|(_, (n, _))| { + n.eq_ignore_ascii_case(p) + || n.replace("US ", "").eq_ignore_ascii_case(p) + || n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p)) + }), + }; + found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| { + bad(format!( + "unknown paper {:?}; choose one of {}", + name.unwrap_or_default(), + PAPERS.iter().map(|(name, _)| *name).collect::>().join(", ") + )) + }) +} + +/// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on +/// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default +/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752). +fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize) -> Result { + let options = match a.get("options") { + None => Vec::new(), + Some(Value::Object(m)) => m + .iter() + .map(|(k, v)| match v.as_str() { + Some(v) => Ok((k.clone(), v.to_string())), + None => Err(bad(format!("options.{k} must be a string (a choice from printer_options)"))), + }) + .collect::>>()?, + Some(_) => return Err(bad("options must be an object of option keys and choices from printer_options")), + }; + Ok(spool::Job { + printer: if printer == "default" { None } else { Some(printer.to_string()) }, + copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32, + collate: a.opt_bool("collate")?.unwrap_or(true), + duplex, + scaling: spool::Scaling::Exact, + grayscale: a.opt_bool("grayscale")?.unwrap_or(false), + title, + options: options.into_iter().chain(print::media_option(paper)).collect(), + }) +} + impl Automation { pub(crate) fn printers(&self) -> Result { let list: Vec = spool::printers() @@ -125,20 +173,7 @@ impl Automation { "form-fields-only" => Content::FormFieldsOnly, c => return Err(bad(format!("unknown comments_forms {c:?}"))), }; - let paper = match a.opt_str("paper")? { - None => PAPERS[0].1, - Some(p) => PAPERS - .iter() - .find(|(n, _)| { - n.eq_ignore_ascii_case(p) - || n.replace("US ", "").eq_ignore_ascii_case(p) - || n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p)) - }) - .map(|x| x.1) - .ok_or_else(|| { - bad(format!("unknown paper {p:?}; choose one of {}", PAPERS.iter().map(|(name, _)| *name).collect::>().join(", "))) - })?, - }; + let (paper_index, paper) = paper_named(a.opt_str("paper")?)?; // Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when // `grayscale` is also asked for. let raster = if a.opt_bool("as_image")?.unwrap_or(false) { @@ -162,27 +197,7 @@ impl Automation { out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { - let name = self.doc(a)?.name.clone(); - let job = spool::Job { - printer: if printer == "default" { None } else { Some(printer.to_string()) }, - copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32, - collate: a.opt_bool("collate")?.unwrap_or(true), - duplex, - scaling: spool::Scaling::Exact, - grayscale: a.opt_bool("grayscale")?.unwrap_or(false), - title: name, - options: match a.get("options") { - None => Vec::new(), - Some(Value::Object(m)) => m - .iter() - .map(|(k, v)| match v.as_str() { - Some(v) => Ok((k.clone(), v.to_string())), - None => Err(bad(format!("options.{k} must be a string (a choice from printer_options)"))), - }) - .collect::>>()?, - Some(_) => return Err(bad("options must be an object of option keys and choices from printer_options")), - }, - }; + let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), @@ -207,4 +222,30 @@ mod tests { assert!(duplex("long-edge", true).is_err()); assert!(duplex("sideways", false).is_err()); } + + /// #3752: `doc_print` to a printer sent no paper, so the queue's default paper stood in for + /// the one the sheets were laid out on (A4 under Letter sheets on the report's queue). + #[test] + fn printing_to_a_printer_feeds_the_laid_out_paper() { + let job = |args: Value, paper_name: Option<&str>| { + let (index, _) = paper_named(paper_name).unwrap(); + spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap() + }; + let media = |m: &str| ("media".to_string(), m.to_string()); + // US Letter when no paper is asked for, after the driver's own options. + let letter = job(json!({ "options": { "InputSlot": "Tray3" } }), None); + assert_eq!(letter.options, [("InputSlot".to_string(), "Tray3".to_string()), media("Letter")]); + assert_eq!((letter.printer.as_deref(), letter.title.as_str()), (Some("Office"), "a.pdf")); + // Every paper, by its full name, goes out as its own media name. + for (i, (name, size)) in PAPERS.iter().enumerate() { + assert_eq!(paper_named(Some(name)).ok(), Some((i, *size)), "{name}"); + assert_eq!(job(json!({}), Some(name)).options, print::media_option(i).into_iter().collect::>(), "{name}"); + } + // Short forms find the same paper. + assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); + assert_eq!(job(json!({}), Some("ARCH D")).options, [media("arch_d_24x36in")]); + assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); + assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None)); + assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); + } } diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 6839e5f6b..80b7be27a 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -307,20 +307,44 @@ pub const PAPERS: [(&str, (f64, f64)); 21] = [ pub const LABEL_PAPER: usize = 19; /// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out -/// on rather than the queue's default (PPD keywords; `w288h432` is the usual 4×6 label, `Env10` -/// the US #10 envelope). +/// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes +/// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). +/// The rest use PWG 5101.1 names (the IANA registry for ARCH E1), which carry their dimensions: +/// CUPS feeds the PPD size of those dimensions whatever the driver calls it (`ARCHD`, `AnsiD`), +/// and driverless IPP printers use these names themselves (#3752). pub fn cups_media(index: usize) -> Option<&'static str> { - match PAPERS.get(index)?.0 { - "US Letter" => Some("Letter"), - "US Legal" => Some("Legal"), - "Tabloid" => Some("Tabloid"), - "A3" => Some("A3"), - "A4" => Some("A4"), - "A5" => Some("A5"), - "US #10 Envelope (4.125 x 9.5 in)" => Some("Env10"), - _ if index == LABEL_PAPER => Some("w288h432"), - _ => None, - } + Some(match PAPERS.get(index)?.0 { + "US Letter" => "Letter", + "US Legal" => "Legal", + "Tabloid" => "Tabloid", + "A3" => "A3", + "A4" => "A4", + "A5" => "A5", + "A6" => "iso_a6_105x148mm", + "ARCH A (9 x 12 in)" => "arch_a_9x12in", + "ARCH B (12 x 18 in)" => "arch_b_12x18in", + "ARCH C (18 x 24 in)" => "arch_c_18x24in", + "ARCH D (24 x 36 in)" => "arch_d_24x36in", + "ARCH E1 (30 x 42 in)" => "na_arch-e1_30x42in", + "ARCH E (36 x 48 in)" => "arch_e_36x48in", + "ANSI C (17 x 22 in)" => "na_c_17x22in", + "ANSI D (22 x 34 in)" => "na_d_22x34in", + "ANSI E (34 x 44 in)" => "na_e_34x44in", + "A2" => "iso_a2_420x594mm", + "A1" => "iso_a1_594x841mm", + "A0" => "iso_a0_841x1189mm", + "US #10 Envelope (4.125 x 9.5 in)" => "Env10", + _ if index == LABEL_PAPER => "w288h432", + // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's + // paper stands, as it did for every size before #1583. + _ => return None, + }) +} + +/// The job option that feeds `PAPERS[index]` (`media`, see [`cups_media`]), for the Print +/// dialog's jobs and `doc_print`'s alike. +pub fn media_option(index: usize) -> Option<(String, String)> { + cups_media(index).map(|m| ("media".to_string(), m.to_string())) } /// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 99634f1aa..a17adef15 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -674,6 +674,34 @@ fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() } } +#[test] +fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { + // #3752: 13 of the 21 sizes had no media name, so their jobs went out on the queue's default + // paper (A4 on the report's queue) whatever the sheets were laid out on. + let ppd_keywords = ["Letter", "Legal", "Tabloid", "A3", "A4", "A5", "w288h432", "Env10"]; + for (i, (name, (w, h))) in PAPERS.iter().enumerate() { + let media = cups_media(i).unwrap_or_else(|| panic!("{name} has no media name")); + assert_eq!(media_option(i), Some(("media".to_string(), media.to_string()))); + let args = lp_args(&Job { options: media_option(i).into_iter().collect(), ..Job::default() }); + assert!(args.contains(&format!("media={media}")), "{name}: lp is given it: {args:?}"); + if ppd_keywords.contains(&media) { + continue; + } + // The rest are PWG 5101.1 names, which end in their dimensions: CUPS feeds the driver's + // size of those dimensions, so they must be the paper's. + let dims = media.rsplit('_').next().unwrap_or_default(); + let (dims, points_per_unit) = match (dims.strip_suffix("mm"), dims.strip_suffix("in")) { + (Some(mm), _) => (mm, 72.0 / 25.4), + (_, Some(inches)) => (inches, 72.0), + _ => panic!("{name}: {media} is not a PWG name"), + }; + let (pw, ph) = dims.split_once('x').unwrap_or_else(|| panic!("{name}: {media} has no dimensions")); + let (pw, ph) = (pw.parse::().unwrap() * points_per_unit, ph.parse::().unwrap() * points_per_unit); + assert!((pw - w).abs() < 0.5 && (ph - h).abs() < 0.5, "{name}: {media} is {pw:.2} x {ph:.2} pt, not {w} x {h}"); + } + assert_eq!((cups_media(PAPERS.len()), media_option(PAPERS.len())), (None, None)); +} + #[test] fn us_number_10_envelope_is_offered_and_fed_by_name() { // 4.125 × 9.5 in (#2734). diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index cab6bfc23..d5d95c881 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -240,7 +240,7 @@ impl PrintDraft { } .into_iter() // The sheet the PDF was laid out on, not the queue's default paper. - .chain(print::cups_media(self.paper).map(|m| ("media".to_string(), m.to_string()))) + .chain(print::media_option(self.paper)) .collect(), } } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 45dda3982..fc56e5f4e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7965,8 +7965,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print", "printers"] -evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page"] -notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name; this change does not alter persistence of other print preferences" +evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page", "crates/print/src/tests.rs::every_paper_reaches_the_spooler_by_a_name_of_its_size", "crates/automation/src/printing.rs::printing_to_a_printer_feeds_the_laid_out_paper"] +notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name; this change does not alter persistence of other print preferences. CUPS jobs from the dialog and doc_print ask for the chosen paper (media) in every size: PPD keywords for the common ones, PWG 5101.1 names for A6, A0–A2, ARCH and ANSI C–E, checked against a synthetic PPD with cupsfilter, not on a physical large-format printer (#3752). On macOS jobs still take the PPD's default paper tray until craft-print sends Auto Select (#3752)" [[feature]] id = "print.printer-selection" From 75947139379cf3c1a00fd180d71b6f999e210134 Mon Sep 17 00:00:00 2001 From: Juan Jurado Date: Thu, 22 Oct 2026 18:24:54 -0600 Subject: [PATCH 084/254] M10: spell the ARCH media names as PWG 5101.1 does (#3752) ARCH A-E went out as arch_a_9x12in .. arch_e_36x48in; PWG 5101.1 names them na_arch-a_9x12in .. na_arch-e_36x48in (ARCH E1's IANA-registered na_arch-e1_30x42in already had the prefix). CUPS parses either from the dimensions, which is why the cupsfilter check passed, but a driverless printer may accept only the names it advertises. Re-checked the corrected names with cupsfilter against the synthetic PPD. The paper test now pins every size's exact name, and fails on the old spelling. Doc comments for doc_print and PrintDraft::job. Review: storytold/pdfcraft#4161 (CodeRabbit). --- crates/automation/src/printing.rs | 4 +++- crates/print/src/lib.rs | 17 ++++++++-------- crates/print/src/tests.rs | 34 +++++++++++++++++++++++++++++-- crates/ui-egui/src/print_ui.rs | 2 ++ 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index e43c3c6e9..111d27eec 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -101,6 +101,8 @@ impl Automation { Ok(json!({ "printer": printer, "count": list.len(), "options": list })) } + /// `doc_print`: lay the document out with the Print dialog's options, then save the + /// print-ready PDF to `path` or send it to `printer` with the chosen paper. pub(crate) fn doc_print(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let (id, count) = (doc.id, doc.info.pages.len()); @@ -243,7 +245,7 @@ mod tests { } // Short forms find the same paper. assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); - assert_eq!(job(json!({}), Some("ARCH D")).options, [media("arch_d_24x36in")]); + assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]); assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None)); assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 80b7be27a..75b6fdf1f 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -309,9 +309,10 @@ pub const LABEL_PAPER: usize = 19; /// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out /// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes /// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). -/// The rest use PWG 5101.1 names (the IANA registry for ARCH E1), which carry their dimensions: -/// CUPS feeds the PPD size of those dimensions whatever the driver calls it (`ARCHD`, `AnsiD`), -/// and driverless IPP printers use these names themselves (#3752). +/// The rest use PWG 5101.1 names (for ARCH E1, which 5101.1 lacks, the IANA registry's), spelled +/// exactly: a driverless printer may take only the names it advertises. They carry their +/// dimensions, so CUPS feeds the PPD size of those dimensions whatever the driver calls it +/// (`ARCHD`, `AnsiD`) (#3752). pub fn cups_media(index: usize) -> Option<&'static str> { Some(match PAPERS.get(index)?.0 { "US Letter" => "Letter", @@ -321,12 +322,12 @@ pub fn cups_media(index: usize) -> Option<&'static str> { "A4" => "A4", "A5" => "A5", "A6" => "iso_a6_105x148mm", - "ARCH A (9 x 12 in)" => "arch_a_9x12in", - "ARCH B (12 x 18 in)" => "arch_b_12x18in", - "ARCH C (18 x 24 in)" => "arch_c_18x24in", - "ARCH D (24 x 36 in)" => "arch_d_24x36in", + "ARCH A (9 x 12 in)" => "na_arch-a_9x12in", + "ARCH B (12 x 18 in)" => "na_arch-b_12x18in", + "ARCH C (18 x 24 in)" => "na_arch-c_18x24in", + "ARCH D (24 x 36 in)" => "na_arch-d_24x36in", "ARCH E1 (30 x 42 in)" => "na_arch-e1_30x42in", - "ARCH E (36 x 48 in)" => "arch_e_36x48in", + "ARCH E (36 x 48 in)" => "na_arch-e_36x48in", "ANSI C (17 x 22 in)" => "na_c_17x22in", "ANSI D (22 x 34 in)" => "na_d_22x34in", "ANSI E (34 x 44 in)" => "na_e_34x44in", diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index a17adef15..e7777fa1a 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -674,10 +674,10 @@ fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() } } +/// #3752: 13 of the 21 sizes had no media name, so their jobs went out on the queue's default +/// paper (A4 on the report's queue) whatever the sheets were laid out on. #[test] fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { - // #3752: 13 of the 21 sizes had no media name, so their jobs went out on the queue's default - // paper (A4 on the report's queue) whatever the sheets were laid out on. let ppd_keywords = ["Letter", "Legal", "Tabloid", "A3", "A4", "A5", "w288h432", "Env10"]; for (i, (name, (w, h))) in PAPERS.iter().enumerate() { let media = cups_media(i).unwrap_or_else(|| panic!("{name} has no media name")); @@ -700,6 +700,36 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { assert!((pw - w).abs() < 0.5 && (ph - h).abs() < 0.5, "{name}: {media} is {pw:.2} x {ph:.2} pt, not {w} x {h}"); } assert_eq!((cups_media(PAPERS.len()), media_option(PAPERS.len())), (None, None)); + // The names themselves, spelled as PWG 5101.1 has them: a driverless printer may take only the + // names it advertises, so one CUPS can merely parse (ARCH A was `arch_a_9x12in`) is not enough. + // ARCH E1 isn't in 5101.1; its name is the IANA registry's. + let names: Vec<(&str, &str)> = PAPERS.iter().enumerate().map(|(i, (name, _))| (*name, cups_media(i).unwrap_or_default())).collect(); + assert_eq!( + names, + [ + ("US Letter", "Letter"), + ("US Legal", "Legal"), + ("Tabloid", "Tabloid"), + ("A3", "A3"), + ("A4", "A4"), + ("A5", "A5"), + ("A6", "iso_a6_105x148mm"), + ("ARCH A (9 x 12 in)", "na_arch-a_9x12in"), + ("ARCH B (12 x 18 in)", "na_arch-b_12x18in"), + ("ARCH C (18 x 24 in)", "na_arch-c_18x24in"), + ("ARCH D (24 x 36 in)", "na_arch-d_24x36in"), + ("ARCH E1 (30 x 42 in)", "na_arch-e1_30x42in"), + ("ARCH E (36 x 48 in)", "na_arch-e_36x48in"), + ("ANSI C (17 x 22 in)", "na_c_17x22in"), + ("ANSI D (22 x 34 in)", "na_d_22x34in"), + ("ANSI E (34 x 44 in)", "na_e_34x44in"), + ("A2", "iso_a2_420x594mm"), + ("A1", "iso_a1_594x841mm"), + ("A0", "iso_a0_841x1189mm"), + ("4 x 6 in label (101.6 x 152.4 mm)", "w288h432"), + ("US #10 Envelope (4.125 x 9.5 in)", "Env10"), + ] + ); } #[test] diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index d5d95c881..12a941b15 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -223,6 +223,8 @@ impl PrintDraft { self.handling == Handling::Multiple && self.order == PageOrder::CutStack } + /// The spooler job for this print: the dialog's copies, two-sided and colour settings, the + /// driver options changed from the printer's defaults, and the chosen paper. pub fn job(&self, title: &str) -> spool::Job { spool::Job { printer: self.printer.clone(), From 635b42d9899a671b8e82d50033d970b16643c40f Mon Sep 17 00:00:00 2001 From: Juan Jurado Date: Thu, 22 Oct 2026 18:42:49 -0600 Subject: [PATCH 085/254] M10: ARCH E1 goes out as PWG 5101.1's na_wide-format_30x42in (#3752) 30 x 42 in is `na_wide-format_30x42in` in PWG 5101.1-2023 and the IANA IPP registry (checked against both). `na_arch-e1_30x42in` was asked for on the IPP list in 2021 but never registered. CUPS maps the new name to the PPD's 30 x 42 in size (cupsfilter, synthetic PPD), and the paper test pins it and fails on the old name. Doc comments for bad, printers and two paper tests, for the review's docstring check. Review: storytold/pdfcraft#4161 (CodeRabbit). --- crates/automation/src/printing.rs | 3 +++ crates/print/src/lib.rs | 10 +++++----- crates/print/src/tests.rs | 10 +++++++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 111d27eec..74e732814 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -22,6 +22,7 @@ fn duplex(value: &str, cut_stack: bool) -> Result> { }) } +/// An invalid-arguments error saying `m`. fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) } @@ -75,6 +76,8 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option Result { let list: Vec = spool::printers() .into_iter() diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 75b6fdf1f..d85e766f1 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -309,10 +309,10 @@ pub const LABEL_PAPER: usize = 19; /// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out /// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes /// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). -/// The rest use PWG 5101.1 names (for ARCH E1, which 5101.1 lacks, the IANA registry's), spelled -/// exactly: a driverless printer may take only the names it advertises. They carry their -/// dimensions, so CUPS feeds the PPD size of those dimensions whatever the driver calls it -/// (`ARCHD`, `AnsiD`) (#3752). +/// The rest use PWG 5101.1 names (ARCH E1 is its `wide-format` size), spelled exactly as the +/// spec and the IANA registry have them: a driverless printer may take only the names it +/// advertises. They carry their dimensions, so CUPS feeds the PPD size of those dimensions +/// whatever the driver calls it (`ARCHD`, `AnsiD`) (#3752). pub fn cups_media(index: usize) -> Option<&'static str> { Some(match PAPERS.get(index)?.0 { "US Letter" => "Letter", @@ -326,7 +326,7 @@ pub fn cups_media(index: usize) -> Option<&'static str> { "ARCH B (12 x 18 in)" => "na_arch-b_12x18in", "ARCH C (18 x 24 in)" => "na_arch-c_18x24in", "ARCH D (24 x 36 in)" => "na_arch-d_24x36in", - "ARCH E1 (30 x 42 in)" => "na_arch-e1_30x42in", + "ARCH E1 (30 x 42 in)" => "na_wide-format_30x42in", "ARCH E (36 x 48 in)" => "na_arch-e_36x48in", "ANSI C (17 x 22 in)" => "na_c_17x22in", "ANSI D (22 x 34 in)" => "na_d_22x34in", diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index e7777fa1a..d93bdf745 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -661,6 +661,8 @@ fn architectural_sheets_preserve_full_scale() { assert!(fit[0].placed[0].matrix.0[0] < 1.0, "Fit adds margins and must not masquerade as full scale"); } +/// A sheet matches a preset either way round and rounded to whole points, but not half a point off; +/// empty, negative, infinite, NaN and custom sizes match none. #[test] fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() { for (i, (_, (w, h))) in PAPERS.iter().enumerate() { @@ -702,7 +704,8 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { assert_eq!((cups_media(PAPERS.len()), media_option(PAPERS.len())), (None, None)); // The names themselves, spelled as PWG 5101.1 has them: a driverless printer may take only the // names it advertises, so one CUPS can merely parse (ARCH A was `arch_a_9x12in`) is not enough. - // ARCH E1 isn't in 5101.1; its name is the IANA registry's. + // ARCH E1 is 5101.1's `wide-format` size: `na_arch-e1_30x42in` was asked for once and never + // registered. let names: Vec<(&str, &str)> = PAPERS.iter().enumerate().map(|(i, (name, _))| (*name, cups_media(i).unwrap_or_default())).collect(); assert_eq!( names, @@ -718,7 +721,7 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { ("ARCH B (12 x 18 in)", "na_arch-b_12x18in"), ("ARCH C (18 x 24 in)", "na_arch-c_18x24in"), ("ARCH D (24 x 36 in)", "na_arch-d_24x36in"), - ("ARCH E1 (30 x 42 in)", "na_arch-e1_30x42in"), + ("ARCH E1 (30 x 42 in)", "na_wide-format_30x42in"), ("ARCH E (36 x 48 in)", "na_arch-e_36x48in"), ("ANSI C (17 x 22 in)", "na_c_17x22in"), ("ANSI D (22 x 34 in)", "na_d_22x34in"), @@ -732,9 +735,10 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { ); } +/// #2734: the US #10 envelope (4.125 × 9.5 in) is offered, fed by its PPD name and laid out at +/// its own size. #[test] fn us_number_10_envelope_is_offered_and_fed_by_name() { - // 4.125 × 9.5 in (#2734). let index = matching_paper((684.0, 297.0)).unwrap(); assert_eq!(PAPERS[index].0, "US #10 Envelope (4.125 x 9.5 in)"); assert_eq!(PAPERS[index].1, (297.0, 684.0)); From dd6d7feadb1024b519035bd9c099e1849e541f3d Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:18:11 -0400 Subject: [PATCH 086/254] M12: a date is tried in the field's first pattern again only when its format refused it --- crates/engine/src/lib.rs | 13 ++++++++----- crates/engine/src/tests.rs | 18 ++++++++++++++++++ crates/forms/src/lib.rs | 6 +++++- crates/forms/src/tests.rs | 2 +- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bde8f0960..8ff666b86 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2534,13 +2534,16 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> 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) { + match set(doc, value) { + Ok(()) => {} + // A date the field's format doesn't read may be typed by another of an XFA date + // field's patterns: it is kept in the first. Only a format refusal is tried + // again: it comes before any of the field's scripts run, so they run once. + Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { Some(typed) => set(doc, &typed)?, None => return Err(refused.into()), - } + }, + Err(refused) => return Err(refused.into()), } } Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed9e22c6b..11963bdd8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7305,3 +7305,21 @@ fn a_date_typed_by_another_of_an_xfa_fields_edit_patterns_is_kept_in_its_first() 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")); } + +/// A date the field's own scripts refuse is not tried again in another of an XFA date field's +/// patterns: only a date its format refuses is, which happens before any script runs, so the +/// scripts run once. This validate script counts its runs in `name` and accepts on its second. +#[test] +fn a_date_its_validate_script_refuses_is_not_tried_again() { + 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 script = Some("var n = Number(getField('name').value || 0) + 1; getField('name').value = String(n); event.rc = n >= 2;".to_string()); + s.apply(id, Edit::SetFieldScript { name: "leave".into(), event: "validate".into(), script }).unwrap(); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + // Its format reads 18OCT26, and so does its DDMMMYY pattern. + let refused = s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("18OCT26".into()) }); + assert!(refused.is_err(), "the validate script refused it, once"); + assert!(value(&s, "name").is_empty(), "nothing the refused run changed is kept: {:?}", value(&s, "name")); +} diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 7bd87a2ef..d4dc56748 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -48,6 +48,10 @@ pub enum FormError { ReadOnly(String), #[error("{0}")] Invalid(String), + /// The value doesn't fit the field's format (its keystroke check refused it), which is + /// checked before any of the field's scripts run. + #[error("{0}")] + Format(String), #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } @@ -1044,7 +1048,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut return invalid(format!("{:?} takes at most {max} characters", f.name)); } // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. - let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Invalid)?; + let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; let t = &scripting::accept(doc, f, t, scripts)?; doc.update_dict(f.obj, |d| { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b2b8573eb..df8d8c35f 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1270,7 +1270,7 @@ fn formats_validation_and_calculations_run_like_acrobat() { // Keystroke and validation errors use Acrobat's messages and change nothing. assert_eq!( set_value(&mut doc, "Qty", &FieldValue::Text("lots".into())), - Err(FormError::Invalid("The value entered does not match the format of the field [ Qty ]".into())) + Err(FormError::Format("The value entered does not match the format of the field [ Qty ]".into())) ); assert_eq!( set_value(&mut doc, "Qty", &FieldValue::Text("120".into())), From f41abf921f28283f4ff19110c71663d7382b0775 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:31:13 +0800 Subject: [PATCH 087/254] M6: dotted field names make parent fields (#2696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2), so Prepare a form refused MASTER.first. Acrobat reads such a name as a full name: the field `first` under a parent field `MASTER`. - add_field: a dotted name makes or joins the parent fields it names. - set_props: a dotted name is the full name and moves the field there, copying what it inherited onto it first; a plain name still renames the field within its parent. Either way the new full name may not be taken by, or hold or sit inside, another field. - A field joining an existing parent doesn't take its Ff or Q. - Field Properties and the Prepare a form name tags show the full name instead of the last part. - Names are capped at 32 parts; empty parts are refused. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/tools.rs | 4 +- crates/forms/src/author.rs | 242 ++++++++++++++++++++++++++------- crates/forms/src/tests.rs | 61 +++++++++ crates/ui-egui/src/dialogs.rs | 6 +- crates/ui-egui/src/prepare.rs | 6 +- parity/acrobat-features.toml | 4 +- 6 files changed, 263 insertions(+), 60 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4fff59c07..0830a7c49 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -349,7 +349,7 @@ pub fn tools() -> Vec { t( "form_add_field", "Prepare form: add a field", - "Add a form field on a page. type: text, date, checkbox, radio, combo, list, button, image (a picture placeholder; fill it with form_set_image), signature. rect in points from the top-left of the displayed page [x0, y0, x1, y1]. name defaults to Acrobat's next free name (Text1, Check Box1, Group1, Dropdown1, List Box1, Button1, Image1, Signature1, Date1). Radio buttons join the radio group named by group (a new group otherwise) with the export value export. Returns the field's name. Undoable.", + "Add a form field on a page. type: text, date, checkbox, radio, combo, list, button, image (a picture placeholder; fill it with form_set_image), signature. rect in points from the top-left of the displayed page [x0, y0, x1, y1]. name defaults to Acrobat's next free name (Text1, Check Box1, Group1, Dropdown1, List Box1, Button1, Image1, Signature1, Date1); a dotted name such as MASTER.first makes the field first under the parent field MASTER. Radio buttons join the radio group named by group (a new group otherwise) with the export value export. Returns the field's name. Undoable.", ) .with(schema( json!({ @@ -408,7 +408,7 @@ pub fn tools() -> Vec { t( "form_set_props", "Field properties", - "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", + "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", ) .with(schema( json!({ diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 544d0a7f8..ab4403fcc 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -486,6 +486,163 @@ fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), Fo Ok(()) } +/// The most parts a dotted field name may have ("a.b.c" has three). +const MAX_NAME_PARTS: usize = 32; + +/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1). +const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"]; + +/// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a +/// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696). +fn name_parts(name: &str) -> Result, FormError> { + let parts: Vec<&str> = name.split('.').take(MAX_NAME_PARTS.saturating_add(1)).collect(); + if parts.len() > MAX_NAME_PARTS { + return invalid(format!("field names can have at most {MAX_NAME_PARTS} parts")); + } + if parts.iter().any(|p| p.trim().is_empty()) { + return invalid("field names can't be empty, or have an empty part between periods"); + } + Ok(parts) +} + +/// Why the full name `name` can't be used beside the field named `other`, if it can't: the +/// same name, or one inside the other (a field that has a value can't also hold fields). +fn clash(name: &str, other: &str) -> Option { + if name == other { + return Some(format!("a field named {name:?} already exists")); + } + let inside = |a: &str, b: &str| a.strip_prefix(b).is_some_and(|rest| rest.starts_with('.')); + (inside(name, other) || inside(other, name)).then(|| format!("{name:?} can't be used beside the field {other:?}")) +} + +/// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field). +fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec { + let d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() +} + +/// Add `field` to `parent`'s `/Kids`, or to the form's `/Fields` at the top. +fn link(doc: &mut Document, af: ObjRef, parent: Option, field: ObjRef) -> Result<(), FormError> { + let Some(p) = parent else { return add_to_fields(doc, af, field) }; + let mut kids = list_of(doc, p, b"Kids"); + kids.push(Object::Ref(field)); + doc.update_dict(p, |d| d.set(b"Kids".to_vec(), Object::Array(kids)))?; + Ok(()) +} + +/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, +/// and the parts before it name parent fields, reused where the tree has them and made where +/// not (Acrobat makes `a.b` a field `b` under a field `a`). +fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { + let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + let mut parent: Option = None; + let mut inherits = false; + for part in parents { + let list = match parent { + Some(p) => list_of(doc, p, b"Kids"), + None => list_of(doc, af, b"Fields"), + }; + let found = list.iter().filter_map(|o| o.as_ref()).find(|r| { + doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(*part) + }); + let next = match found { + Some(r) => { + inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); + r + } + None => { + let mut d = Dict::new(); + d.set(b"T".to_vec(), PdfString::text(part)); + d.set(b"Kids".to_vec(), Object::Array(Vec::new())); + if let Some(p) = parent { + d.set(b"Parent".to_vec(), Object::Ref(p)); + } + let r = doc.add(Object::Dict(d)); + link(doc, af, parent, r)?; + r + } + }; + parent = Some(next); + } + doc.update_dict(field, |d| { + d.set(b"T".to_vec(), PdfString::text(own)); + match parent { + Some(p) => d.set(b"Parent".to_vec(), Object::Ref(p)), + None => { + d.remove(b"Parent"); + } + } + // An existing parent's flags and alignment would otherwise pass to this field. + if inherits { + for k in [&b"Ff"[..], b"Q"] { + if !d.contains(k) { + d.set(k.to_vec(), Object::Int(0)); + } + } + } + })?; + link(doc, af, parent, field) +} + +/// Copy onto `field` what it inherits from its parents, so it keeps them when it moves. +fn own_inherited(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { + let own = doc.get(field).as_dict().cloned().unwrap_or_default(); + let mut add = Dict::new(); + let mut seen = std::collections::HashSet::new(); + let mut parent = own.reference(b"Parent"); + while let Some(p) = parent { + if seen.len() > 64 || !seen.insert(p) { + break; + } + let Some(d) = doc.get(p).as_dict().cloned() else { break }; + for k in INHERITED_KEYS { + if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) { + add.set(k.to_vec(), v.clone()); + } + } + parent = d.reference(b"Parent"); + } + if add.iter().next().is_some() { + doc.update_dict(field, |d| { + for (k, v) in add.iter() { + d.set(k.to_vec(), v.clone()); + } + })?; + } + Ok(()) +} + +/// Take `field` out of the field tree: from its parent's `/Kids`, or from `/Fields`. +fn detach(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { + let parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); + let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { + let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); + d.set(key.to_vec(), Object::Array(kept)); + doc.set(holder, Object::Dict(d)); + Ok(()) + }; + match parent { + Some(p) => remove_from(doc, p, b"Kids")?, + None => { + let root = doc.root().ok_or(FormError::NoForm)?; + match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { + Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, + // A form dictionary held directly in the catalog. + Some(Object::Dict(mut af)) => { + let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); + af.set(b"Fields".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + _ => {} + } + } + } + Ok(()) +} + /// The first free "N" name. fn free_name(existing: &[Field], base: &str) -> String { (1..=u64::MAX) @@ -527,20 +684,18 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel _ => name, }; let name = match name.map(str::trim) { - Some(n) if !n.is_empty() => { - if n.contains('.') { - return invalid("field names can't contain a period"); - } - n.to_string() - } + Some(n) if !n.is_empty() => n.to_string(), _ => free_name(&existing, kind.base_name()), }; + let parts = name_parts(&name)?; let radio_group = match kind { NewField::Radio { group, .. } => existing.iter().find(|f| f.kind == FieldKind::Radio && Some(&f.name) == group.as_ref()).cloned(), _ => None, }; - if radio_group.is_none() && existing.iter().any(|f| f.name == name) { - return invalid(format!("a field named {name:?} already exists")); + if radio_group.is_none() + && let Some(why) = existing.iter().find_map(|f| clash(&name, &f.name)) + { + return invalid(why); } let af = ensure_form(doc)?; let mut w = widget_dict(page_ref, rect); @@ -637,12 +792,11 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel let mut g = Dict::new(); g.set(b"FT".to_vec(), Object::name("Btn")); g.set(b"Ff".to_vec(), Object::Int((flags::RADIO | flags::NO_TOGGLE_TO_OFF) as i64)); - g.set(b"T".to_vec(), PdfString::text(&field_name)); g.set(b"V".to_vec(), Object::name("Off")); g.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); g.set(b"Kids".to_vec(), Object::Array(Vec::new())); let g = doc.add(Object::Dict(g)); - add_to_fields(doc, af, g)?; + attach(doc, af, g, &parts)?; g } }; @@ -669,10 +823,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; } - _ => { - doc.update_dict(widget, |d| d.set(b"T".to_vec(), PdfString::text(&name)))?; - add_to_fields(doc, af, widget)?; - } + _ => attach(doc, af, widget, &parts)?, } add_to_page(doc, page_ref, widget)?; redraw_field(doc, &field_name)?; @@ -919,18 +1070,31 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result<(), FormError> { } } } - // Out of the field tree: from its parent's /Kids, or from /Fields. - let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); - let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { - let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); - let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect(); - d.set(key.to_vec(), Object::Array(kept)); - doc.set(holder, Object::Dict(d)); - Ok(()) - }; - match parent { - Some(p) => remove_from(doc, p, b"Kids")?, - None => { - let root = doc.root().ok_or(FormError::NoForm)?; - match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { - Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, - // A form dictionary held directly in the catalog. - Some(Object::Dict(mut af)) => { - let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect(); - af.set(b"Fields".to_vec(), Object::Array(kept)); - doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - _ => {} - } - } - } + detach(doc, f.obj)?; // 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)) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 4d5a14f57..3b8eae66a 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3056,3 +3056,64 @@ fn a_skipped_field_leaves_its_widget_to_the_next() { let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect(); assert_eq!(widgets, [("b", 1)]); } + +/// #2696: a dotted name is a full name. `MASTER.first` is a field `first` under a parent field +/// `MASTER` (a partial name can't contain a period, ISO 32000-2 §12.7.4.2); a second `MASTER.` +/// name joins that parent; renaming to a dotted name moves the field, keeping what it inherited; +/// and a name that would make one field both hold a value and hold fields is refused. +#[test] +fn dotted_field_names_are_made_with_parent_fields() { + let mut doc = fixture(); + let top = |doc: &Document| { + let root = doc.root().unwrap(); + let af = doc.get(root).as_dict().and_then(|d| d.reference(b"AcroForm")).unwrap(); + doc.get(af).as_dict().and_then(|d| d.get(b"Fields").and_then(|o| o.as_array().map(|a| a.len()))).unwrap() + }; + let own_name = |doc: &Document, f: &Field| doc.get(f.obj).as_dict().and_then(|d| d.get(b"T").and_then(text_of)); + let parent_of = |doc: &Document, f: &Field| doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + let text = NewField::Text { multiline: false }; + let before = top(&doc); + assert_eq!(add_field(&mut doc, 0, [50.0, 300.0, 200.0, 320.0], &text, Some("MASTER.account_firstname")).unwrap(), "MASTER.account_firstname"); + add_field(&mut doc, 0, [50.0, 260.0, 200.0, 280.0], &text, Some("MASTER.street1")).unwrap(); + assert_eq!(top(&doc), before + 1, "one parent field at the top"); + let saved = reopen(&doc); + let all = fields(&saved); + let (a, b) = (field(&all, "MASTER.account_firstname"), field(&all, "MASTER.street1")); + assert_eq!(own_name(&saved, b).as_deref(), Some("street1"), "the field's own name has no period"); + let master = parent_of(&saved, a); + assert_eq!(parent_of(&saved, b), master, "both under one MASTER"); + assert_eq!(saved.get(master).as_dict().and_then(|d| d.get(b"T").and_then(text_of)).as_deref(), Some("MASTER")); + + // Renaming to a dotted name moves the field: zip joins the existing address parent. + assert_eq!(set_props(&mut doc, "zip", &FieldProps { name: Some("address.zip".into()), ..FieldProps::default() }).unwrap(), "address.zip"); + assert_eq!(top(&doc), before, "zip left the top"); + let all = fields(&doc); + let (zip, city) = (field(&all, "address.zip"), field(&all, "address.city")); + assert_eq!(parent_of(&doc, zip), parent_of(&doc, city)); + assert_eq!(zip.max_len, Some(5), "its own settings move with it"); + + // What a moved field inherited stays; what a new field would inherit from a parent it + // joins doesn't apply to it. + let address = parent_of(&doc, city); + doc.update_dict(address, |d| { + d.set(b"Ff".to_vec(), Object::Int(flags::READ_ONLY as i64)); + d.set(b"V".to_vec(), Object::String(PdfString::literal(b"Springfield".to_vec()))); + }) + .unwrap(); + let city = field(&fields(&doc), "address.city").clone(); + assert!(city.read_only() && city.value == ["Springfield"]); + set_props(&mut doc, "address.city", &FieldProps { name: Some("billing.city".into()), ..FieldProps::default() }).unwrap(); + add_field(&mut doc, 0, [300.0, 300.0, 450.0, 320.0], &text, Some("address.street")).unwrap(); + let all = fields(&doc); + let moved = field(&all, "billing.city"); + assert!(moved.read_only() && moved.value == ["Springfield"], "the moved field keeps what it inherited"); + assert!(!field(&all, "address.street").read_only(), "a new field doesn't take its parent's flags"); + + for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] { + assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(bad)).is_err(), "{bad:?}"); + assert!(set_props(&mut doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); + } + let deep = vec!["p"; 33].join("."); + assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep"); + assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0337a8cbf..6f6bf41cf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1360,8 +1360,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { && let Some(i) = app.active { // Keep the (possibly renamed) field selected. - let prefix = d.field.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - let name = format!("{prefix}{}", d.name.trim()); + // A dotted name is the full name; a plain one renames the field within its parent. + let typed = d.name.trim(); + let prefix = d.field.rsplit_once('.').filter(|_| !typed.contains('.')).map(|(p, _)| format!("{p}.")).unwrap_or_default(); + let name = format!("{prefix}{typed}"); if let (Some(before), Some(after)) = (before, form(app)) { let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count(); if n > 0 { diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index f79ebf17f..bb94d52e5 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -560,7 +560,8 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, egui::StrokeKind::Inside, ); // The name tag: white text on black, clipped to the field. - let label = f.name.rsplit('.').next().unwrap_or(&f.name); + // The full name, as Acrobat shows it: MASTER.first, not first (#2696). + let label = f.name.as_str(); let font = theme::regular((9.0 * zoom).clamp(7.0, 13.0)); let galley = painter.layout_no_wrap(label.to_string(), font, Color32::WHITE); let size = galley.size() + vec2(6.0, 2.0); @@ -887,7 +888,8 @@ impl FieldDraft { kind: f.kind, widget, tab: FieldTab::General, - name: f.name.rsplit('.').next().unwrap_or(&f.name).to_string(), + // The full name: a dotted name moves the field under those parent fields (#2696). + name: f.name.clone(), tooltip: f.tooltip.clone().unwrap_or_default(), caption: f.widgets.get(widget).and_then(|w| w.caption.clone()).unwrap_or_default(), read_only: f.read_only(), diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 45dda3982..c7f160665 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5069,8 +5069,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::locked_fields_only_take_unlocking", "crates/ui-egui/tests/it/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties", "crates/forms/src/tests.rs::renaming_a_kid_is_checked_against_the_full_name_it_takes", "crates/forms/src/tests.rs::renaming_a_top_level_field_is_still_checked_against_top_level_names"] -notes = "rename in Field Properties ▸ General; Locked sets the widgets' annotation Locked flag (/F bit 8) and greys out the properties until unlocked; values can still be filled in" +evidence = ["crates/forms/src/tests.rs::locked_fields_only_take_unlocking", "crates/ui-egui/tests/it/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties", "crates/forms/src/tests.rs::renaming_a_kid_is_checked_against_the_full_name_it_takes", "crates/forms/src/tests.rs::renaming_a_top_level_field_is_still_checked_against_top_level_names", "crates/forms/src/tests.rs::dotted_field_names_are_made_with_parent_fields"] +notes = "rename in Field Properties ▸ General, which shows the full name; a dotted name (MASTER.first) is a full name and makes or joins parent fields, as Acrobat does, moving a renamed field with what it inherited (#2696); a plain name renames the field within its parent, so the GUI cannot yet move a nested field to the top level; Locked sets the widgets' annotation Locked flag (/F bit 8) and greys out the properties until unlocked; values can still be filled in" [[feature]] id = "form.tab-order" From 224a64e3d071e3fb8d53c2ab22a9fb152f95a1ca Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:22:04 +0800 Subject: [PATCH 088/254] M6: a dotted name checks the place it goes: an empty parent's name, a parent's value (#2696) Review on #2957. The field list holds only terminal fields, so a parent left empty by a move wasn't seen and a second field of its name could be made beside it, by adding or by renaming. And a new field joining a parent that holds /V, /DV or /MaxLen inherited them, with no neutral value of its own to turn them off: such a parent now takes no field that doesn't hold its own. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/author.rs | 52 ++++++++++++++++++++++++++----- crates/forms/src/tests.rs | 63 +++++++++++++++++++++++++++++++++++--- 2 files changed, 103 insertions(+), 12 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index ab4403fcc..edb6f58f6 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -530,22 +530,51 @@ fn link(doc: &mut Document, af: ObjRef, parent: Option, field: ObjRef) - Ok(()) } +/// The node named `name` (its own `/T`) directly under `parent`, or at the top of the form. +fn child_named(doc: &Document, af: ObjRef, parent: Option, name: &str) -> Option { + let list = match parent { + Some(p) => list_of(doc, p, b"Kids"), + None => list_of(doc, af, b"Fields"), + }; + list.iter() + .filter_map(|o| o.as_ref()) + .find(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(name)) +} + +/// The values a parent field passes to the fields under it that can't be turned off by giving +/// the field a neutral one of its own, as `/Ff` and `/Q` can. +const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"]; + /// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, /// and the parts before it name parent fields, reused where the tree has them and made where /// not (Acrobat makes `a.b` a field `b` under a field `a`). fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + // First check the place, changing nothing. The field list holds only terminal fields, so a + // parent left empty by a move isn't in it: a node of the same name there is found here. + let owned = doc.get(field).as_dict().cloned().unwrap_or_default(); + let mut at: Option = None; + let mut whole_path = true; + for part in parents { + let Some(r) = child_named(doc, af, at, part) else { + whole_path = false; + break; + }; + // A value, reset value or length limit held by a parent would pass to this field. + let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k))); + if held { + return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join("."))); + } + at = Some(r); + } + // `field` is new or already out of the tree, so a node found is another. + if whole_path && child_named(doc, af, at, own).is_some() { + return invalid(format!("a field named {:?} already exists", parts.join("."))); + } let mut parent: Option = None; let mut inherits = false; for part in parents { - let list = match parent { - Some(p) => list_of(doc, p, b"Kids"), - None => list_of(doc, af, b"Fields"), - }; - let found = list.iter().filter_map(|o| o.as_ref()).find(|r| { - doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(*part) - }); - let next = match found { + let next = match child_named(doc, af, parent, part) { Some(r) => { inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); r @@ -1093,6 +1122,13 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Date: Mon, 19 Oct 2026 09:49:10 +0800 Subject: [PATCH 089/254] M6: a move the destination refuses leaves the field in place (#2696) Review on #2957: set_props took the field out of the tree before attach checked the destination, so a refused move (a parent holding /MaxLen, say) left it out of /Fields for a direct caller of the forms API. The read-only check is now its own function, run before the field is detached. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/author.rs | 24 +++++++++++++++++------- crates/forms/src/tests.rs | 10 ++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index edb6f58f6..cdbd24793 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -545,13 +545,11 @@ fn child_named(doc: &Document, af: ObjRef, parent: Option, name: &str) - /// the field a neutral one of its own, as `/Ff` and `/Q` can. const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"]; -/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, -/// and the parts before it name parent fields, reused where the tree has them and made where -/// not (Acrobat makes `a.b` a field `b` under a field `a`). -fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { +/// Whether `field` may go in the field tree under the full name `parts`, changing nothing. The +/// field list holds only terminal fields, so a parent left empty by a move isn't in it: a node +/// of the same name there is found here. +fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; - // First check the place, changing nothing. The field list holds only terminal fields, so a - // parent left empty by a move isn't in it: a node of the same name there is found here. let owned = doc.get(field).as_dict().cloned().unwrap_or_default(); let mut at: Option = None; let mut whole_path = true; @@ -567,10 +565,20 @@ fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Resu } at = Some(r); } - // `field` is new or already out of the tree, so a node found is another. + // A field being moved is still in the tree here, but under another full name (a move to its + // own name changes nothing and doesn't get here), so a node found is another. if whole_path && child_named(doc, af, at, own).is_some() { return invalid(format!("a field named {:?} already exists", parts.join("."))); } + Ok(()) +} + +/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, +/// and the parts before it name parent fields, reused where the tree has them and made where +/// not (Acrobat makes `a.b` a field `b` under a field `a`). +fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { + let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + check_place(doc, af, field, parts)?; let mut parent: Option = None; let mut inherits = false; for part in parents { @@ -1107,6 +1115,8 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Date: Mon, 19 Oct 2026 10:48:01 +0800 Subject: [PATCH 090/254] M6: a refused move doesn't copy inherited values onto the field first (#2696) Review on #2957: own_inherited wrote what the field inherits onto it before check_place could refuse the move, so a refused move still changed the field. What it inherits is now read first, counted as its own by the check, and written only once the move is allowed. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/author.rs | 26 ++++++++++++++++++-------- crates/forms/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index cdbd24793..33a237440 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -548,9 +548,13 @@ const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"]; /// Whether `field` may go in the field tree under the full name `parts`, changing nothing. The /// field list holds only terminal fields, so a parent left empty by a move isn't in it: a node /// of the same name there is found here. -fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { +/// `inherited` is what the field will hold as its own before it moves (see [`inherited`]). +fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str], inherited: &Dict) -> Result<(), FormError> { let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; - let owned = doc.get(field).as_dict().cloned().unwrap_or_default(); + let mut owned = doc.get(field).as_dict().cloned().unwrap_or_default(); + for (k, v) in inherited.iter() { + owned.set(k.to_vec(), v.clone()); + } let mut at: Option = None; let mut whole_path = true; for part in parents { @@ -578,7 +582,7 @@ fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Res /// not (Acrobat makes `a.b` a field `b` under a field `a`). fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; - check_place(doc, af, field, parts)?; + check_place(doc, af, field, parts, &Dict::new())?; let mut parent: Option = None; let mut inherits = false; for part in parents { @@ -621,8 +625,8 @@ fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Resu link(doc, af, parent, field) } -/// Copy onto `field` what it inherits from its parents, so it keeps them when it moves. -fn own_inherited(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { +/// What `field` inherits from its parents and doesn't hold itself, changing nothing. +fn inherited(doc: &Document, field: ObjRef) -> Dict { let own = doc.get(field).as_dict().cloned().unwrap_or_default(); let mut add = Dict::new(); let mut seen = std::collections::HashSet::new(); @@ -639,6 +643,11 @@ fn own_inherited(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { } parent = d.reference(b"Parent"); } + add +} + +/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves. +fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> { if add.iter().next().is_some() { doc.update_dict(field, |d| { for (k, v) in add.iter() { @@ -1114,9 +1123,10 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Date: Thu, 22 Oct 2026 10:47:53 +0800 Subject: [PATCH 091/254] M6: a dotted rename's calculations follow the field (#2696) main now renames a field in the calculations that read it. After rebasing, that runs for a dotted name too, which moves the field under new parents: the sum and the script that read `a` read `Group.a`, and still compute after a save and reopen. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/tests.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index e76be2de8..211f5f7b6 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1321,6 +1321,32 @@ fn renaming_a_field_renames_it_in_the_calculations_that_read_it() { assert_eq!((field(&all, "sum").value.clone(), field(&all, "product").value.clone()), (vec!["12".to_string()], vec!["20".to_string()])); } +/// A dotted name moves the field under new parent fields (#2696), and the calculations that read +/// it follow it to its full new name, as they follow a plain rename. +#[test] +fn a_dotted_rename_moves_the_field_and_its_calculations_follow() { + use crate::af::{CalcOp, Calculate}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["a", "b", "sum", "script"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let calculate = + FieldProps { calculate: Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] }), ..FieldProps::default() }; + set_props(&mut doc, "sum", &calculate).unwrap(); + set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"a\").value;")).unwrap(); + set_props(&mut doc, "a", &FieldProps { name: Some("Group.a".into()), ..FieldProps::default() }).unwrap(); + let mut doc = reopen(&doc); + let all = fields(&doc); + assert!(all.iter().any(|f| f.name == "Group.a") && all.iter().all(|f| f.name != "a")); + assert_eq!(field(&all, "sum").actions.calculate, Calculate::Simple { op: CalcOp::Sum, fields: vec!["Group.a".into(), "b".into()] }); + assert_eq!(field(&all, "script").actions.scripts.calculate.as_deref(), Some("event.value = getField(\"Group.a\").value;")); + set_value(&mut doc, "Group.a", &FieldValue::Text("10".into())).unwrap(); + set_value(&mut doc, "b", &FieldValue::Text("2".into())).unwrap(); + assert_eq!(field(&fields(&doc), "sum").value, ["12"]); +} + /// The rename reaches a script stored as a stream, an indirect action and the only widget's /// actions; it leaves a script that isn't UTF-8 as it is, and a name another field still has. #[test] From 5e4ca789b8cf07d1463c57f440005686fd3c9687 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:52:38 +0800 Subject: [PATCH 092/254] M6: note why a dotted move that fails half way never reaches the document; the parity note says the table tool still refuses periods (#2696) Review on #2957 (benandez, points 2 and 3). Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/author.rs | 4 ++++ parity/acrobat-features.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 33a237440..5370f2590 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1126,6 +1126,10 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Date: Fri, 23 Oct 2026 08:39:57 +0800 Subject: [PATCH 093/254] M6: a typed field name loses its ends but keeps spaces beside periods (#2696) Review on #2957, point 1. Both entry points already trim the whole name before splitting it, and a space beside a period stays in its part, which matches black-box observation of Acrobat and ISO 32000-2 12.7.4.2 (only a period is barred from a partial name). The test pins that down; three mutants (no trim in add_field, no trim in set_props, untrimmed empty-part check) each fail it. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 211f5f7b6..ae532f8ff 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3228,3 +3228,35 @@ fn dotted_field_names_are_made_with_parent_fields() { assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep"); assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); } + +/// Review on #2957: spaces in a typed name. The ends of the whole name are dropped, as Acrobat +/// does when a field is renamed; a space beside a period stays in the part it belongs to (`a. b` +/// is `b` with a leading space under `a`), as Acrobat keeps it and as ISO 32000-2 §12.7.4.2 +/// allows: only a period is barred from a partial name. +#[test] +fn a_typed_name_loses_its_ends_but_keeps_spaces_beside_periods() { + let mut doc = fixture(); + let own_name = |doc: &Document, full: &str| { + let all = fields(doc); + doc.get(field(&all, full).obj).as_dict().and_then(|d| d.get(b"T").and_then(text_of)) + }; + let text = NewField::Text { multiline: false }; + assert_eq!(add_field(&mut doc, 0, [50.0, 300.0, 200.0, 320.0], &text, Some(" plain ")).unwrap(), "plain"); + assert_eq!(add_field(&mut doc, 0, [50.0, 260.0, 200.0, 280.0], &text, Some("a. b")).unwrap(), "a. b"); + assert_eq!(add_field(&mut doc, 0, [50.0, 220.0, 200.0, 240.0], &text, Some("c .d")).unwrap(), "c .d"); + assert_eq!(set_props(&mut doc, "zip", &FieldProps { name: Some(" e. f ".into()), ..FieldProps::default() }).unwrap(), "e. f"); + let saved = reopen(&doc); + assert_eq!(own_name(&saved, "plain").as_deref(), Some("plain")); + assert_eq!(own_name(&saved, "a. b").as_deref(), Some(" b")); + assert_eq!(own_name(&saved, "c .d").as_deref(), Some("d")); + assert_eq!(own_name(&saved, "e. f").as_deref(), Some(" f")); + let parent = |full: &str| { + let all = fields(&saved); + let p = saved.get(field(&all, full).obj).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + saved.get(p).as_dict().and_then(|d| d.get(b"T").and_then(text_of)) + }; + assert_eq!(parent("c .d").as_deref(), Some("c "), "the space before the period is the parent's"); + assert_eq!(parent("e. f").as_deref(), Some("e")); + // A part that is only spaces is still empty. + assert!(add_field(&mut doc, 0, [300.0, 300.0, 450.0, 320.0], &text, Some("g. .h")).is_err()); +} From 5ee279290a6fddf6763fedbee3d7e1da396b2064 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 04:25:50 +0200 Subject: [PATCH 094/254] =?UTF-8?q?M11:=20PDF=20Optimizer=20=E2=96=B8=20Cl?= =?UTF-8?q?ean=20Up=20stores=20LZW=20streams=20with=20Flate=20(optimize.fl?= =?UTF-8?q?ate-unencoded)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ATTRIBUTION.toml | 66 ++++---- Cargo.lock | 1 + crates/automation/src/lib.rs | 2 + crates/automation/src/tools.rs | 3 +- crates/automation/tests/it/automation.rs | 50 ++++++ crates/optimize/Cargo.toml | 1 + crates/optimize/README.md | 6 +- crates/optimize/src/lib.rs | 128 ++++++++++++++- crates/optimize/tests/optimize.rs | 198 +++++++++++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/ckb.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/kk.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/lo.tsv | 1 + crates/ui-egui/src/i18n/lt.tsv | 1 + crates/ui-egui/src/i18n/nb.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ro.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sl.tsv | 1 + crates/ui-egui/src/i18n/sq.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/th.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/optimize_ui.rs | 1 + parity/acrobat-features.toml | 4 +- 44 files changed, 454 insertions(+), 39 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 3f42a5401..911fb50f9 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 = "1902f1c31698e6adfdaf65b35b1fe1628f5cd2bd0182dd3b99bcf5e21720f394" +sha256 = "f6f451f25716c7b542d61f772fc52b0ac742e528e1725f88e6438f50de83685b" # 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 = "2e23c41fcd5b9118cfde03b3a45aa2f988f7eeba650677b6f1f6664e543c7a59" +sha256 = "5f85e70f547ad70e9c3a53c3eddc6a56125868bd40271da8a93dbb3da58d8898" [[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 = "105703f2fbc07c0663308ebd6852199de12a5851a37507d7e51e477eb5e57218" +sha256 = "a764edaaf6fa2123c348c7e07b7fcaa00c0c80fa0a4196e74962db3c43d27668" [[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 = "b08f8282923d2faaf1f658d2aff8f71fe5f2d3a33e792eeb50fb0e2b396ef24f" +sha256 = "5ac28a6ebde1d64a0c66dac327b86556c2e52a4b02fa3402fa743c6c534611d7" [[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 = "df17dada3df736e75bd9b03e5ecceec164cce63767e33a1efdb98c56a121c890" +sha256 = "1e516d10b2d8f4a13b9f22a5be892aa40280d29757823472e5c2d1e0f3d90833" [[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 = "8e8c0cfda0886c584e5286370ec00814206c3af3699c76665e636fa6fdb6310b" +sha256 = "36460fa583d1bbe469bcb1d332d6e9dd4cce5803db4497c1e8b185e9ccdc1a98" [[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 = "3f31e5c9abdb0208d48d8c68aa81437d9d1e10aeb615dedf811fefa539d93279" +sha256 = "2ed7411670f084424427306ef1a9d1a498f0c38a3a6f2917058a6394108f3c95" [[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 = "815a3a672421cc3590fb1f267755316659cdb2046e30bbb3ad8b31e96f08b20a" +sha256 = "5a668835a7d4852bf4e81290290777b9ddc440ff6f73d7898d2eda2057a75a42" [[asset]] path = "crates/ui-egui/src/i18n/lo.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6bd67286795ed487eedf2ce549249c8a83286cb4273490c1349996050b540fed" +sha256 = "08d1862a7185a27d180c3af52c02e05a052ab6b5a9fa91d8904ed92fbe6e0c99" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -118,7 +118,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 = "9cd63425705fb9a3aa029ff3a5110d0877a34f9038f1d64a2102ccd6c3cb35a6" +sha256 = "995221cf9dbfd85ce565205d500f3f6160cafa0e4dd9562ad8ba04994252d1fe" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -129,7 +129,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 = "76f138293fc12ea3584568a81f6f90a1cedfff6256e78b4f60cb21b1b66bbba4" +sha256 = "df08daffe1d39ab7f957e6f42e3ff5cea6fb463c27bf697495a934071d6c07a6" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -140,7 +140,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 = "0bd75a93f8d18deb832223bd0642cc4f4abde04a322b684a143a9d4b4687a344" +sha256 = "704de81fc1892b3b7d3ec1f67869939e8034d7ace700b98aaf550dd228157898" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -151,7 +151,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 = "3d539a28afcf8c2e36b7087ac23334b50bac7971e03da20a62b6e7e400763a87" +sha256 = "a63f2fa2f18790a4d1feb5a79be00cff342b7d93d4df176990cce73b34a576fa" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -162,7 +162,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 = "71b7e8180fc759aebad4660953ab8b55383def0572c1ace2288cd8bfeb1b82dc" +sha256 = "44601c04ba0029f3af1767b1f045467823fca9aa09e81826f2bc551d240237b2" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -173,7 +173,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 = "e689fb24a8acfa7e85f0f60644f7e2da1225595e6cd6a94109fbe86082926244" +sha256 = "2525dfa1bc461cfe7531a1e5dd5bbef41d89690e33457dbd4cde64ae1ae34954" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -184,7 +184,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 = "c601ea117ec2a3cdba606fdc9a62220fcffbd0c20c1133bacb653a979cf046a5" +sha256 = "d694ce0975ce09537f565abe94f204b618cc79c4e000af96693115c8bbb508d3" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -195,7 +195,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 = "006867b891ae2f995c7a711e55b6401db29d20ab493889c6284dc3671f9faa2d" +sha256 = "83f80bb06b9dd4b6c77e8f4f62aea71c3fcc67d1df2fdef95a2c43077d2771ad" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -206,7 +206,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 = "9e5b18a09c209525cf08ef53a242ed457cfa346295eef7facde482a75cf72f52" +sha256 = "384b20f09727e50e191e270171c29a08e8cc4eadc77417234d75407ed4d17d04" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -217,7 +217,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 = "2306dbbe6a73671fd20df116adb159a7f66c85fbbd1bae5ecef5f6e882d6b7f1" +sha256 = "dea69e04b75e61c33ee30cb13739a1279cb44ded4163524ba2124dce5095987f" [[asset]] path = "crates/ui-egui/src/i18n/ckb.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "864db8aa0a5e443c92e7e388f89349eb0f0e7beda35a0b81695d864dcec51b72" +sha256 = "5710c3739425f7228274e9c30cf9f675b664db154971def67850e60d0468f461" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -239,7 +239,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 = "463a2d28bd5c5ac8df31a5b5c68400d7a61ce51237a58d9337078c973e6f9e3d" +sha256 = "12cf178181235a54ded7cad2e0699a20ff4db5c589d239fae73ae3ec7d65d793" [[asset]] path = "crates/ui-egui/src/i18n/lt.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 = "bbef7f4e16f83e9e0116278e7a0a175bad79757f96a18dc639516572533f6d5b" +sha256 = "81dc2d3c750ac9df73f4a8a5ab7e3723905479ec51d92d63994cf14ff9890a0d" [[asset]] path = "crates/ui-egui/src/i18n/hi.tsv" @@ -261,7 +261,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "09d2840c02b40a6049b86c2cb68151d863913e8c91c22226f25d07496c6366aa" +sha256 = "f3ad03739d6bc50263eb819159c7ac6823c5bf3326f4677f081742ffb8fe32ab" [[asset]] path = "crates/ui-egui/src/i18n/id.tsv" title = "Indonesian interface translation catalog" @@ -271,7 +271,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0dc1abdb262e4568f19ffb2233bfeb3686ae42df8a8d8bec2951351a50e6a886" +sha256 = "a6357827206369cd49b1f9b374bef43e5dc10afa3329015509033101f1d812bf" [[asset]] path = "crates/ui-egui/src/i18n/ko.tsv" title = "Korean interface translation catalog" @@ -281,7 +281,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a3e67cee4f3fff776e816523f810b2bb6353235dbdf85181c3f12896604a3339" +sha256 = "595a8c32d74c169ac22c0e4f73bd8e00551708a7501647e37b78de0ebc2e5103" [[asset]] path = "crates/ui-egui/src/i18n/ro.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 = "00a2153c20a191fbe04f1ce61f140fd9628cfe2f8f75dad5bce8a7406e1bd091" +sha256 = "4b34cd917fa4863e46ca18ed296cec715a1a40c56fca0b26ad29a638c835f84f" [[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 = "609efa92054aa76de4bb6062c0fcc0a8b450519f4b253b4c0f395b3cbb34d2ba" +sha256 = "7447236441d1768a10a0dd8c9e156222a5c4e2435e62e8dcc7b1918d6bba9973" [[asset]] path = "assets/icons/accessibility.svg" @@ -3426,7 +3426,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "57f0dd007ad16518fbc87d8aeac840ef6d86412bb460aff9fb17b176408fb7a8" +sha256 = "0464b388d829e23d644d10bd54427c96189c2ffb3c9cc39a0299a6d85c267e35" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3437,7 +3437,7 @@ 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 = "a3b8351f0da80c2d394221dbe5d62e794bb019c74c2adfc5d933d999f7d3236f" +sha256 = "dff23a9b66b5e6f49ff7387bc04a26769548c798427f4cf99046cadbc00bc063" [[asset]] @@ -3460,7 +3460,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "03477c4ab2d0d52543cb91b3aec6f27b2689e6b9bb7c95bbb8463664c75a9dc0" +sha256 = "d69fec16fe7d422b1e1f6f1d7a3481ca49d8d0ab68098989b2f6b54adecdf2ef" [[asset]] path = "assets/icons/superscript.svg" @@ -3515,7 +3515,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ebd177262d8fe20f960f06df084f8a03c9504bcd9990dded3a006e35bd8ef7c9" +sha256 = "6a190b4b04393d926adb3b259ba74f05ed4f87ce8f839b98624e8bad58307658" [[asset]] path = "crates/ui-egui/src/i18n/th.tsv" @@ -3526,7 +3526,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1dbb66c05af0aec06d5a6fa154e89f1684203dde32c8e8aedd9aeec6c2d5ae02" +sha256 = "7de17b6bddd33ea916f32e035bdd96d2531d208737ddd04f2dcfe56980d78aec" [[asset]] path = "crates/ui-egui/src/i18n/ca.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 = "5879ee3540ffb630fc83d0f2cb293cc6cbf2e081ea4577161a770794e8cbd22c" +sha256 = "9afc2be3720e39bbcdc8ebf5ea475d21e2580b844d97621d5b30d025ac3756f8" diff --git a/Cargo.lock b/Cargo.lock index 47d73be44..c5735eb04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4235,6 +4235,7 @@ dependencies = [ "pdfcraft-annot", "pdfcraft-content", "pdfcraft-cos", + "pdfcraft-filters", "pdfcraft-model", "pdfcraft-render", "thiserror 2.0.21", diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 522705bb1..df91b8902 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1226,6 +1226,7 @@ impl Automation { ("discard_tags", &mut settings.discard_tags), ("discard_print_settings", &mut settings.discard_print_settings), ("flate_unencoded", &mut settings.flate_unencoded), + ("lzw_to_flate", &mut settings.lzw_to_flate), ("remove_invalid_links", &mut settings.remove_invalid_links), ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), ] { @@ -1264,6 +1265,7 @@ impl Automation { "alternate_images": o.alternate_images, "tags_removed": o.tags_removed, "streams_compressed": o.streams_compressed, + "lzw_streams": o.lzw_streams, "invalid_links": o.invalid_links, "invalid_bookmarks": o.invalid_bookmarks, "unreferenced_dests": o.unreferenced_dests, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4fff59c07..028d58d68 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1359,7 +1359,7 @@ 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. 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); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode (lzw_streams); 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!({ @@ -1374,6 +1374,7 @@ pub fn tools() -> Vec { "discard_tags": { "type": "boolean" }, "discard_print_settings": { "type": "boolean" }, "flate_unencoded": { "type": "boolean" }, + "lzw_to_flate": { "type": "boolean" }, "remove_invalid_links": { "type": "boolean" }, "remove_unreferenced_dests": { "type": "boolean" }, "discard": { "type": "array", "items": { "type": "string" } }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 778deaaec..8f966bc46 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5580,6 +5580,56 @@ fn optimizing_through_tools() { assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_)))); } +/// `data` as LZW with one 9-bit literal code per byte (clear-table first, EOD last): valid for +/// fewer than 250 bytes, before the code width grows. +fn lzw_literals(data: &[u8]) -> Vec { + let codes: Vec = std::iter::once(256).chain(data.iter().map(|&b| u16::from(b))).chain([257]).collect(); + let bits: Vec = codes.iter().flat_map(|c| (0..9).rev().map(move |i| c >> i & 1 == 1)).collect(); + bits.chunks(8).map(|byte| byte.iter().enumerate().fold(0u8, |acc, (i, &b)| acc | (u8::from(b) << (7 - i)))).collect() +} + +#[test] +fn lzw_streams_are_stored_with_flate_through_tools() { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full}; + let dir = workdir("optimize-lzw"); + let ops = b"0.2 0.4 0.8 rg 72 72 200 100 re f 0 0 1 RG 72 300 m 400 500 l S".to_vec(); + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let lzw = Stream::from_raw(Dict::from_iter([(b"Filter".to_vec(), Object::name("LZWDecode"))]), lzw_literals(&ops)); + assert_eq!(lzw.decoded_strict().unwrap(), ops); + let content = doc.add(Object::Stream(lzw)); + let page = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Page")), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])), + (b"Contents".to_vec(), Object::Ref(content)), + ]))); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + std::fs::write(dir.join("lzw.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "lzw.pdf" }))["doc"].as_u64().unwrap(); + let filters = |name: &str| { + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).unwrap())).unwrap(); + let catalog = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap(); + let pages = saved.get(catalog.reference(b"Pages").unwrap()).as_dict().cloned().unwrap(); + let page = saved.get(pages.get(b"Kids").and_then(|k| k.as_array()?.first()?.as_ref()).unwrap()).as_dict().cloned().unwrap(); + match &*saved.get(page.reference(b"Contents").unwrap()) { + Object::Stream(s) => (s.dict.get(b"Filter").cloned(), s.decoded_strict().unwrap()), + _ => panic!("the page content is not a stream"), + } + }; + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "flate.pdf" })); + assert_eq!(r["lzw_streams"].as_u64(), Some(1), "{r}"); + assert_eq!(filters("flate.pdf"), (Some(Object::name("FlateDecode")), ops.clone())); + let off = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "lzw_to_flate": false })); + assert_eq!(off["lzw_streams"].as_u64(), Some(0), "{off}"); + assert_eq!(filters("kept.pdf"), (Some(Object::name("LZWDecode")), ops)); +} + /// A one-page PDF whose page picture is drawn as `rows` grey images one pixel row high, 600 px /// wide on a 72 pt square (600 ppi), as some PDF printers write it. fn printed_in_strips(rows: usize) -> Vec { diff --git a/crates/optimize/Cargo.toml b/crates/optimize/Cargo.toml index 184ee9697..3b3bbac75 100644 --- a/crates/optimize/Cargo.toml +++ b/crates/optimize/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } +pdfcraft-filters = { workspace = true } pdfcraft-content = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-annot = { workspace = true } diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 3d517eef3..387c26cb0 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -30,7 +30,11 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going - **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 - bytes). Streams with `/DecodeParms`, images and object streams are left alone. + bytes). Streams with `/DecodeParms`, images and object streams are left alone. LZW-compressed + streams, content and images alike, are decoded and stored with Flate instead (Acrobat's "In + streams that use LZW encoding, use Flate instead"): a predictor carries over to Flate without + `EarlyChange`, filters applied before the LZW one go with it and those after it keep their + bytes. A stream whose LZW data doesn't decode cleanly is left as it is. Not yet: inline images. 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 diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index c8b9b6769..14472106a 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -14,7 +14,7 @@ //! print settings. //! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted //! image); Flate-compress streams that have no filter; remove a redundant outer ASCII85 or -//! ASCIIHex layer from streams that have one. +//! ASCIIHex layer from streams that have one; store LZW-compressed streams with Flate instead. //! //! Metadata, attachments, comments, scripts, private data, hidden layers, bookmarks and form //! fields are discarded by `pdfcraft-redact`'s Remove Hidden Information, which the engine @@ -30,7 +30,8 @@ mod unused; pub use audit::{SpaceCategory, SpaceUse, audit_space}; -use pdfcraft_cos::{Document, ObjRef, Object, Stream, serialize}; +use pdfcraft_cos::{Dict, Document, MAX_PAGE_DECODED, ObjRef, Object, Stream, serialize}; +use pdfcraft_filters::{Filter, Params}; pub use images::effective_resolutions; @@ -100,6 +101,8 @@ 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: decode LZW-compressed streams and store them with Flate instead. + pub lzw_to_flate: bool, /// Clean Up: remove links (and bookmark destinations) that point nowhere. pub remove_invalid_links: bool, /// Clean Up: remove named destinations nothing refers to. @@ -118,6 +121,7 @@ impl Default for Settings { discard_tags: false, discard_print_settings: false, flate_unencoded: true, + lzw_to_flate: true, remove_invalid_links: true, remove_unreferenced_dests: true, } @@ -178,6 +182,8 @@ pub struct Report { pub tags_removed: bool, pub print_settings: usize, pub streams_compressed: usize, + /// Streams whose LZW compression was replaced with Flate (Clean Up). + pub lzw_streams: usize, /// Links removed and bookmarks whose destination was cleared (Clean Up). pub invalid_links: usize, pub invalid_bookmarks: usize, @@ -279,6 +285,19 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: if settings.remove_unreferenced_dests { report.unreferenced_dests = links::remove_unreferenced_dests(doc)?; } + // Before the unencoded-stream pass, which would otherwise take the ASCII layer off an + // `[/ASCII85Decode /LZWDecode]` stream first and count it twice. + if settings.lzw_to_flate { + for num in doc.object_numbers() { + let r = ObjRef { num, generation: doc.generation(num) }; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + if let Some(replacement) = lzw_to_flate(s, MAX_LZW_DECODED) { + doc.set(r, Object::Stream(replacement)); + report.lzw_streams += 1; + } + } + } if settings.flate_unencoded { for num in doc.object_numbers() { let r = ObjRef { num, generation: doc.generation(num) }; @@ -305,6 +324,94 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: Ok(report) } +/// Largest decoded LZW stream converted, as for a page's content: the data and its check are +/// held together, so a few hundred kilobytes of hostile LZW can't ask for gigabytes. +const MAX_LZW_DECODED: usize = MAX_PAGE_DECODED; + +/// `s` with its LZW compression replaced by Flate (Acrobat's "In streams that use LZW encoding, +/// use Flate instead"). The filters up to the last LZW one are decoded, strictly and with its +/// predictor; the result is Flate-encoded under the same predictor, without `EarlyChange`, and +/// the filters after it keep their encoded bytes. A stream that doesn't decode cleanly, or +/// whose filters or parameters can't be read for certain, or that decodes past `max` bytes, is +/// left as it is. +fn lzw_to_flate(s: &Stream, max: usize) -> Option { + // An external file's data isn't in `raw`; cross-reference and object streams are rewritten + // on save, so converting them would change nothing in the file. + if s.dict.contains(b"F") || matches!(s.dict.name(b"Type"), Some(b"XRef" | b"ObjStm")) { + return None; + } + let names: Vec<&[u8]> = match s.dict.get(b"Filter")? { + Object::Name(n) => vec![n.as_slice()], + Object::Array(a) => a.iter().map(Object::as_name).collect::>()?, + _ => return None, + }; + // `/Crypt` was applied when the object was loaded, and dropping it would change how the + // stream is encrypted. + if names.contains(&&b"Crypt"[..]) { + return None; + } + let lzw = names.iter().rposition(|n| matches!(*n, b"LZWDecode" | b"LZW"))?; + // One entry per filter, each a dictionary or null; anything else (an indirect reference, an + // array of the wrong length) would be read as defaults, so the data might not decode as + // written. + let parms: Vec> = match s.dict.get(b"DecodeParms") { + None | Some(Object::Null) => vec![None; names.len()], + Some(Object::Dict(d)) if names.len() == 1 => vec![Some(d)], + Some(Object::Array(a)) if a.len() == names.len() => a + .iter() + .map(|p| match p { + Object::Dict(d) => Some(Some(d)), + Object::Null => Some(None), + _ => None, + }) + .collect::>()?, + _ => return None, + }; + let chain = s.filters(); + let stages = chain.get(..=lzw)?; + let (_, params) = stages.last()?; + let data = pdfcraft_filters::decode(stages, &s.raw, max).ok()?; + let flate = Params { early_change: 1, ..params.clone() }; + let raw = pdfcraft_filters::encode(&Filter::Flate, &flate, &data).ok()?; + // The predictor's encoder must give back exactly what its decoder read (a short last row, + // unusual parameters), or the stream would change. The limit counts a PNG predictor's tag + // bytes too, at most one per row of at least one byte. + if pdfcraft_filters::decode(&[(Filter::Flate, flate)], &raw, data.len().saturating_mul(2).saturating_add(1)).ok()? != data { + return None; + } + // Without a predictor the other parameters say nothing to Flate. + let first = parms.get(lzw).copied().flatten().filter(|_| params.predictor > 1).map(|d| { + let mut d = d.clone(); + d.remove(b"EarlyChange"); + Object::Dict(d) + }); + let rest_names = names.get(lzw + 1..)?; + let rest_parms = parms.get(lzw + 1..)?; + let mut dict = s.dict.clone(); + dict.remove(b"Length"); + if rest_names.is_empty() { + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + match first { + Some(p) => dict.set(b"DecodeParms".to_vec(), p), + None => { + dict.remove(b"DecodeParms"); + } + } + } else { + let filters = std::iter::once(Object::name("FlateDecode")).chain(rest_names.iter().map(|n| Object::Name(n.to_vec()))); + dict.set(b"Filter".to_vec(), Object::Array(filters.collect())); + let parms: Vec = std::iter::once(first.unwrap_or(Object::Null)) + .chain(rest_parms.iter().map(|p| p.map_or(Object::Null, |d| Object::Dict(d.clone())))) + .collect(); + if parms.iter().all(|p| matches!(p, Object::Null)) { + dict.remove(b"DecodeParms"); + } else { + dict.set(b"DecodeParms".to_vec(), Object::Array(parms)); + } + } + Some(Stream::from_raw(dict, raw)) +} + /// `s` without its outer ASCII85 or ASCIIHex layer (`[/ASCII85Decode /FlateDecode]` becomes /// `[/FlateDecode]`), when that is smaller. Only the outer layer is decoded: strictly, and within /// the stored size, which ASCII text never falls below, so the inner encoded bytes stay exact. @@ -335,3 +442,20 @@ fn stored_len(s: &Stream) -> Option { serialize(&Object::Dict(dict), &mut out); out.len().checked_add(s.raw.len()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_lzw_stream_that_decodes_past_the_limit_is_left_as_it_is() { + let data = b"0 0 m 612 792 l S\n".repeat(64); + let raw = pdfcraft_filters::encode(&Filter::Lzw, &Params::default(), &data).unwrap(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("LZWDecode")); + let s = Stream::from_raw(dict, raw); + assert!(lzw_to_flate(&s, data.len() - 1).is_none()); + let converted = lzw_to_flate(&s, data.len()).unwrap(); + assert_eq!(converted.decoded_strict().unwrap(), data); + } +} diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 6cf53d4fc..b0049a1c6 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; +use pdfcraft_filters::{Filter, Params}; use pdfcraft_optimize::{Compression, ImageSettings, Settings, SpaceCategory, audit_space, effective_resolutions, optimize}; fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option) -> ObjRef { @@ -1097,3 +1098,200 @@ fn a_hostile_soft_mask_leaves_its_image_unchanged() { assert_eq!(stream(&doc, photo), before); assert_eq!(stream(&doc, mask), hostile); } + +/// Page content long enough for LZW codes to grow past 9 bits, where `EarlyChange` matters. +fn lzw_ops() -> Vec { + (0..300).flat_map(|i| format!("{} {} m {} {} l S\n", i % 97, i * 7 % 389, i * 13 % 612, i * 31 % 792).into_bytes()).collect() +} + +fn lzw(data: &[u8], params: &Params) -> Vec { + pdfcraft_filters::encode(&Filter::Lzw, params, data).unwrap() +} + +/// `params` as a `/DecodeParms` dictionary, only the entries that differ from the defaults. +fn decode_parms(p: &Params) -> Dict { + let mut d = Dict::new(); + for (k, v, default) in [ + (&b"Predictor"[..], p.predictor, 1), + (b"Colors", p.colors, 1), + (b"BitsPerComponent", p.bits_per_component, 8), + (b"Columns", p.columns, 1), + (b"EarlyChange", p.early_change, 1), + ] { + if v != default { + d.set(k.to_vec(), Object::Int(v)); + } + } + d +} + +/// A 64 × 48 RGB image, LZW-encoded with `params`. +fn lzw_image(doc: &mut Document, params: &Params) -> (ObjRef, Vec) { + let (w, h) = (64u32, 48u32); + let px: Vec = (0..w * h).flat_map(|i| [(i % w * 4) as u8, (i / w * 5) as u8, ((i % w + i / w) * 2) as u8]).collect(); + 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.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + d.set(b"Filter".to_vec(), Object::name("LZWDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(params))); + (doc.add(Object::Stream(Stream::from_raw(d, lzw(&px, params)))), px) +} + +/// Settings that leave images to the clean-up: no resampling, no recompression. +fn clean_up_only() -> Settings { + let keep = ImageSettings { downsample: false, compression: Compression::Retain, ..ImageSettings::REDUCE }; + Settings { color: keep, gray: keep, ..Settings::default() } +} + +#[test] +fn lzw_streams_become_flate_and_decode_to_the_same_bytes() { + let ops = lzw_ops(); + let early = Params::default(); + let late = Params { early_change: 0, ..Params::default() }; + let png = Params { predictor: 15, colors: 3, columns: 64, ..Params::default() }; + let png_late = Params { early_change: 0, ..png.clone() }; + let mut doc = Document::new_empty(); + let content = |doc: &mut Document, p: &Params| { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("LZWDecode")); + if *p != Params::default() { + d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(p))); + } + doc.add(Object::Stream(Stream::from_raw(d, lzw(&ops, p)))) + }; + let c_early = content(&mut doc, &early); + let c_late = content(&mut doc, &late); + let (img, px) = lzw_image(&mut doc, &png); + let (img_late, _) = lzw_image(&mut doc, &png_late); + page(&mut doc, &[("Im", img), ("Im2", img_late)], "q 64 0 0 48 72 600 cm /Im Do Q q 64 0 0 48 172 600 cm /Im2 Do Q"); + let pg = pdfcraft_annot::page_refs(&doc).unwrap()[0]; + doc.update_dict(pg, |d| { + d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(c_early), Object::Ref(c_late), d.get(b"Contents").unwrap().clone()])) + }) + .unwrap(); + // Each LZW stream reads as its own data before, so a decoder ignoring EarlyChange fails here. + assert_eq!((stream(&doc, c_early).decoded_strict().unwrap(), stream(&doc, c_late).decoded_strict().unwrap()), (ops.clone(), ops.clone())); + assert!(lzw(&ops, &early) != lzw(&ops, &late), "the two code widths differ on this data"); + + let r = optimize(&mut doc, &clean_up_only()).unwrap(); + assert_eq!(r.lzw_streams, 4, "{r:?}"); + for (label, at, data) in + [("content", c_early, &ops), ("content, EarlyChange 0", c_late, &ops), ("image", img, &px), ("image, EarlyChange 0", img_late, &px)] + { + let after = stream(&doc, at); + assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode")), "{label}"); + assert_eq!(&after.decoded_strict().unwrap(), data, "{label}"); + let parms = after.dict.get(b"DecodeParms").and_then(Object::as_dict); + assert!(parms.is_none_or(|p| !p.contains(b"EarlyChange")), "{label}: EarlyChange is LZW's alone"); + } + // The image keeps its PNG predictor, now applied under Flate. + let parms = stream(&doc, img).dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!((parms.int(b"Predictor"), parms.int(b"Colors"), parms.int(b"Columns")), (Some(15), Some(3), Some(64))); + assert!(!stream(&doc, c_late).dict.contains(b"DecodeParms"), "nothing left to say for the content stream"); +} + +#[test] +fn a_page_drawn_from_lzw_streams_renders_the_same_after_conversion() { + let png = Params { predictor: 12, colors: 3, columns: 64, early_change: 0, ..Params::default() }; + let mut doc = Document::new_empty(); + let (img, _) = lzw_image(&mut doc, &png); + let pg = page(&mut doc, &[("Im", img)], ""); + let ops = b"0.2 0.5 0.8 rg 36 36 300 200 re f q 256 0 0 192 72 400 cm /Im Do Q\n".repeat(4); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("LZWDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(&Params { early_change: 0, ..Params::default() }))); + let c = doc.add(Object::Stream(Stream::from_raw(d, lzw(&ops, &Params { early_change: 0, ..Params::default() })))); + doc.update_dict(pg, |p| p.set(b"Contents".to_vec(), Object::Ref(c))).unwrap(); + let render = |doc: &Document| { + 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: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + out.rgba + }; + let before = render(&doc); + // The picture and the rectangle are drawn: the page is not blank. + assert!(before.chunks(4).any(|p| p[..3] != [255, 255, 255])); + let r = optimize(&mut doc, &clean_up_only()).unwrap(); + assert_eq!(r.lzw_streams, 2, "{r:?}"); + assert_eq!(stream(&doc, c).dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert!(render(&doc) == before, "the page renders pixel for pixel as before"); +} + +#[test] +fn lzw_inside_a_filter_chain_is_replaced_and_the_other_stages_kept() { + let ops = lzw_ops(); + let inner = lzw(&ops, &Params::default()); + // An ASCII85 layer outside the LZW goes with it; a JPEG inside it stays encoded as it was. + let (mut doc, c) = with_content(filtered(&["ASCII85Decode", "LZWDecode"], ascii85(&inner))); + let photo = image(&mut doc, 40, 30, 3, true, None); + let jpeg = stream(&doc, photo).raw.to_vec(); + let mut picture = filtered(&["LZWDecode", "DCTDecode"], lzw(&jpeg, &Params::default())); + picture.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Null])); + let pic = doc.add(Object::Stream(picture)); + // An ASCII85 layer over LZW with a PNG predictor: the predictor's parameters move to the one + // Flate filter left, without EarlyChange. + let rows: Vec = (0..40 * 30).map(|i| (i % 40 * 3 + i / 40) as u8).collect(); + let png = Params { predictor: 12, columns: 40, early_change: 0, ..Params::default() }; + let mut predicted = filtered(&["ASCII85Decode", "LZWDecode"], ascii85(&lzw(&rows, &png))); + predicted.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(decode_parms(&png))])); + let pred = doc.add(Object::Stream(predicted)); + let r = optimize(&mut doc, &clean_up_only()).unwrap(); + assert_eq!(r.lzw_streams, 3, "{r:?}"); + let after = stream(&doc, pred); + assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode"))); + let parms = after.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!((parms.int(b"Predictor"), parms.int(b"Columns"), parms.contains(b"EarlyChange")), (Some(12), Some(40), false)); + assert_eq!(after.decoded_strict().unwrap(), rows); + let after = stream(&doc, c); + assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode"))); + assert_eq!(after.decoded_strict().unwrap(), ops); + let after = stream(&doc, pic); + assert_eq!(after.dict.get(b"Filter"), Some(&Object::Array(vec![Object::name("FlateDecode"), Object::name("DCTDecode")]))); + assert!(!after.dict.contains(b"DecodeParms"), "all null: dropped"); + let inside = pdfcraft_filters::decode(&[(Filter::Flate, Params::default())], &after.raw, 1 << 20).unwrap(); + assert_eq!(inside, jpeg, "the JPEG bytes are kept exactly"); +} + +#[test] +fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() { + let ops = lzw_ops(); + let good = lzw(&ops, &Params::default()); + // 9-bit codes 511 after a literal: past the table, corrupt. + let corrupt = vec![0x80, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + assert!(filtered(&["LZWDecode"], corrupt.clone()).decoded_strict().is_err()); + let mut bad_predictor = filtered(&["LZWDecode"], good.clone()); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(7)); + bad_predictor.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let mut indirect_parms = filtered(&["LZWDecode"], good.clone()); + indirect_parms.dict.set(b"DecodeParms".to_vec(), Object::Ref(ObjRef { num: 999, generation: 0 })); + let mut objstm = filtered(&["LZWDecode"], good.clone()); + objstm.dict.set(b"Type".to_vec(), Object::name("ObjStm")); + let mut external = filtered(&["LZWDecode"], good.clone()); + external.dict.set(b"F".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"data.bin".to_vec()))); + let cases = [ + ("corrupt LZW", filtered(&["LZWDecode"], corrupt), Settings::default()), + ("corrupt under a PNG predictor", bad_predictor, Settings::default()), + ("indirect DecodeParms", indirect_parms, Settings::default()), + ("external file", external, Settings::default()), + ("object stream, rewritten on save", objstm, Settings::default()), + ("crypt filter", filtered(&["Crypt", "LZWDecode"], good.clone()), Settings::default()), + ("option off", filtered(&["LZWDecode"], good.clone()), Settings { lzw_to_flate: false, ..Settings::default() }), + ]; + for (label, s, settings) in cases { + let (mut doc, c) = with_content(s.clone()); + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.lzw_streams, 0, "{label}"); + assert_eq!(stream(&doc, c), s, "{label}"); + } + // The same data, readable and asked for, is converted: the cases above fail for their reason. + let (mut doc, c) = with_content(filtered(&["LZWDecode"], good)); + assert_eq!(optimize(&mut doc, &Settings::default()).unwrap().lzw_streams, 1); + assert_eq!(stream(&doc, c).decoded_strict().unwrap(), ops); +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 765bbba55..3b8e5c138 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -611,6 +611,7 @@ Discard private data of other applications تجاهل البيانات الخاصة بالتطبيقات الأخرى Discard hidden text تجاهل النص المخفي Use Flate to encode streams that are not encoded استخدام Flate لترميز التدفقات غير المرمَّزة + In streams that use LZW encoding, use Flate instead في التدفقات التي تستخدم ترميز LZW، استخدام Flate بدلاً منه Remove invalid links and bookmarks إزالة الروابط والإشارات المرجعية غير الصالحة Remove unreferenced named destinations إزالة الوجهات المسماة غير المشار إليها Compress document structure (object streams) ضغط بنية المستند (تدفقات الكائنات) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9bb377a93..efa132ed3 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -282,6 +282,7 @@ Discard private data of other applications Премахни частните данни на други приложения Discard hidden text Премахни скрития текст Use Flate to encode streams that are not encoded Кодирай с Flate некодираните потоци + In streams that use LZW encoding, use Flate instead Кодирай с Flate потоците, кодирани с LZW Remove invalid links and bookmarks Премахни невалидните връзки и отметки Remove unreferenced named destinations Премахни неизползваните именувани местоназначения Compress document structure (object streams) Компресирай структурата на документа (потоци от обекти) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index dd2f497b8..83962d0cb 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -574,6 +574,7 @@ Discard private data of other applications Descarta les dades privades d'altres aplicacions Discard hidden text Descarta el text ocult Use Flate to encode streams that are not encoded Utilitza Flate per codificar els fluxos que no estan codificats + In streams that use LZW encoding, use Flate instead Als fluxos que utilitzen codificació LZW, utilitza Flate Remove invalid links and bookmarks Elimina els enllaços i marcadors no vàlids Remove unreferenced named destinations Elimina les destinacions amb nom sense referència Compress document structure (object streams) Comprimeix l'estructura del document (fluxos d'objectes) diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 804e7a97a..e290099f5 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -581,6 +581,7 @@ Discard private data of other applications فڕێدانی داتا تایبەتەکانی بەرنامەکانی تر Discard hidden text فڕێدانی دەقی شاراوە Use Flate to encode streams that are not encoded بەکارهێنانی Flate بۆ کۆدکردنی ئەو لێشاوانەی کۆد نەکراون + In streams that use LZW encoding, use Flate instead بەکارهێنانی Flate لە جیاتی LZW بۆ ئەو لێشاوانەی بە LZW کۆد کراون Remove invalid links and bookmarks لابردنی بەستەر و دڵخوازە نادروستەکان Remove unreferenced named destinations لابردنی مەبەستە ناونراوەکانی ئاماژەپێنەکراو Compress document structure (object streams) پەستاندنی پێکهاتەی بەڵگەنامە (لێشاوەکانی تەن) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 66cac29aa..ef380d623 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -692,6 +692,7 @@ key Space Mezerník Discard private data of other applications Zahodit soukromá data jiných aplikací Discard hidden text Zahodit skrytý text Use Flate to encode streams that are not encoded Kódovat nekódované proudy metodou Flate + In streams that use LZW encoding, use Flate instead Proudy kódované LZW kódovat metodou Flate Remove invalid links and bookmarks Odebrat neplatné odkazy a záložky Remove unreferenced named destinations Odebrat nepoužívané pojmenované cíle Compress document structure (object streams) Komprimovat strukturu dokumentu (proudy objektů) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 76366d452..75c4b6274 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -584,6 +584,7 @@ Discard private data of other applications Private Daten anderer Anwendungen verwerfen Discard hidden text Ausgeblendeten Text verwerfen Use Flate to encode streams that are not encoded Nicht codierte Streams mit Flate codieren + In streams that use LZW encoding, use Flate instead In LZW-codierten Streams stattdessen Flate verwenden Remove invalid links and bookmarks Ungültige Links und Lesezeichen entfernen Remove unreferenced named destinations Nicht referenzierte benannte Ziele entfernen Compress document structure (object streams) Dokumentstruktur komprimieren (Objekt-Streams) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 18464664a..7c8160901 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1923,6 +1923,7 @@ Discard private data of other applications Αφαίρεση ιδιωτικών δεδομένων άλλων εφαρμογών Discard hidden text Αφαίρεση κρυφού κειμένου Use Flate to encode streams that are not encoded Χρήση συμπίεσης Flate για ροές που δεν είναι συμπιεσμένες + In streams that use LZW encoding, use Flate instead Χρήση Flate αντί για LZW στις ροές με κωδικοποίηση LZW Remove invalid links and bookmarks Αφαίρεση μη έγκυρων συνδέσμων και σελιδοδεικτών Remove unreferenced named destinations Αφαίρεση ονομασμένων προορισμών χωρίς αναφορές Compress document structure (object streams) Συμπίεση δομής εγγράφου (ροές αντικειμένων) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 73b0ffbf1..bfdb3b10f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -607,6 +607,7 @@ Discard private data of other applications Descartar datos privados de otras aplicaciones Discard hidden text Descartar texto oculto Use Flate to encode streams that are not encoded Usar Flate para codificar las secuencias sin codificar + In streams that use LZW encoding, use Flate instead En las secuencias con codificación LZW, usar Flate en su lugar Remove invalid links and bookmarks Quitar enlaces y marcadores no válidos Remove unreferenced named destinations Quitar destinos con nombre sin referencias Compress document structure (object streams) Comprimir la estructura del documento (secuencias de objetos) diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index efcf3e3c6..2142dd98a 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -574,6 +574,7 @@ Discard private data of other applications Hylkää muiden sovellusten yksityiset tiedot Discard hidden text Hylkää piilotettu teksti Use Flate to encode streams that are not encoded Koodaa koodaamattomat virrat Flate-menetelmällä + In streams that use LZW encoding, use Flate instead Koodaa LZW-koodatut virrat Flate-menetelmällä Remove invalid links and bookmarks Poista virheelliset linkit ja kirjanmerkit Remove unreferenced named destinations Poista viittaamattomat nimetyt kohteet Compress document structure (object streams) Pakkaa asiakirjan rakenne (objektivirrat) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ec6e59e56..82c17ed7d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -584,6 +584,7 @@ Discard private data of other applications Ignorer les données privées d'autres applications Discard hidden text Ignorer le texte masqué Use Flate to encode streams that are not encoded Utiliser Flate pour coder les flux qui ne sont pas codés + In streams that use LZW encoding, use Flate instead Dans les flux qui utilisent le codage LZW, utiliser Flate à la place Remove invalid links and bookmarks Retirer les liens et signets non valides Remove unreferenced named destinations Retirer les destinations nommées non référencées Compress document structure (object streams) Compresser la structure du document (flux d'objets) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7be5ec688..d81157c75 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -558,6 +558,7 @@ Discard private data of other applications अन्य ऐप्लिकेशन का निजी डेटा हटाएँ Discard hidden text छिपा हुआ टेक्स्ट हटाएँ Use Flate to encode streams that are not encoded जो स्ट्रीम एन्कोडेड नहीं हैं उन्हें Flate से एन्कोड करें + In streams that use LZW encoding, use Flate instead LZW एन्कोडिंग वाली स्ट्रीम में इसके बजाय Flate का उपयोग करें Remove invalid links and bookmarks अमान्य लिंक और बुकमार्क हटाएँ Remove unreferenced named destinations बिना संदर्भ वाले नामित गंतव्य हटाएँ Compress document structure (object streams) दस्तावेज़ संरचना संपीड़ित करें (ऑब्जेक्ट स्ट्रीम) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f1723629c..e8c45743b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -582,6 +582,7 @@ Discard private data of other applications Más alkalmazások privát adatainak elvetése Discard hidden text Rejtett szöveg elvetése Use Flate to encode streams that are not encoded A kódolatlan adatfolyamok Flate kódolása + In streams that use LZW encoding, use Flate instead Az LZW kódolású adatfolyamok Flate kódolása Remove invalid links and bookmarks Érvénytelen hivatkozások és könyvjelzők eltávolítása Remove unreferenced named destinations Hivatkozás nélküli elnevezett célok eltávolítása Compress document structure (object streams) Dokumentumszerkezet tömörítése (objektumfolyamok) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3585b344e..6e730763d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1219,6 +1219,7 @@ Update Perbarui Use 72 DPI (one point per pixel) Gunakan 72 DPI (satu titik per piksel) Use Flate to encode streams that are not encoded Gunakan Flate untuk mengodekan stream yang belum dikodekan + In streams that use LZW encoding, use Flate instead Pada stream yang memakai pengodean LZW, gunakan Flate sebagai gantinya Use a Digital ID from a file Gunakan ID Digital dari file Use custom DPI Gunakan DPI kustom Use each image's embedded DPI; use 72 DPI when it is absent. Gunakan DPI tertanam pada setiap gambar; gunakan 72 DPI jika tidak ada. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0dc6bb087..1c941e6c4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -608,6 +608,7 @@ Discard private data of other applications Elimina dati privati di altre applicazioni Discard hidden text Elimina testo nascosto Use Flate to encode streams that are not encoded Usa Flate per codificare i flussi non codificati + In streams that use LZW encoding, use Flate instead Nei flussi con codifica LZW, usa Flate Remove invalid links and bookmarks Rimuovi collegamenti e segnalibri non validi Remove unreferenced named destinations Rimuovi destinazioni con nome non referenziate Compress document structure (object streams) Comprimi struttura del documento (flussi di oggetti) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 963e8d364..ae6f48b81 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -611,6 +611,7 @@ Discard private data of other applications 他のアプリケーションのプライベートデータを破棄 Discard hidden text 非表示テキストを破棄 Use Flate to encode streams that are not encoded 未エンコードのストリームを Flate でエンコード + In streams that use LZW encoding, use Flate instead LZW エンコードのストリームを Flate でエンコード Remove invalid links and bookmarks 無効なリンクとしおりを削除 Remove unreferenced named destinations 参照されていない名前付き移動先を削除 Compress document structure (object streams) 文書構造を圧縮(オブジェクトストリーム) diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 311626f34..3c3f938b0 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -571,6 +571,7 @@ Discard private data of other applications Басқа қосымшалардың жеке деректерін алып тастау Discard hidden text Жасырын мәтінді алып тастау Use Flate to encode streams that are not encoded Кодталмаған ағындарды Flate көмегімен кодтау + In streams that use LZW encoding, use Flate instead LZW кодталған ағындарда оның орнына Flate қолдану Remove invalid links and bookmarks Жарамсыз сілтемелерді және бетбелгілерді алып тастау Remove unreferenced named destinations Сілтеме берілмеген атаулы межелерді алып тастау Compress document structure (object streams) Құжат құрылымын сығу (нысан ағындары) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 48ef31e19..e754e5b28 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -558,6 +558,7 @@ Discard private data of other applications 다른 응용 프로그램의 개인 데이터 삭제 Discard hidden text 숨겨진 텍스트 삭제 Use Flate to encode streams that are not encoded 인코딩되지 않은 스트림을 Flate로 인코딩 + In streams that use LZW encoding, use Flate instead LZW 인코딩 스트림을 Flate로 인코딩 Remove invalid links and bookmarks 잘못된 링크 및 책갈피 제거 Remove unreferenced named destinations 참조되지 않은 이름이 지정된 대상 제거 Compress document structure (object streams) 문서 구조 압축(개체 스트림) diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index a8a9f6f64..66e1cbd7a 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -579,6 +579,7 @@ Discard private data of other applications ຕັດຂໍ້ມູນສ່ວນຕົວຂອງແອັບພລິເຄຊັນອື່ນອອກ Discard hidden text ຕັດຂໍ້ຄວາມທີ່ເຊື່ອງໄວ້ອອກ Use Flate to encode streams that are not encoded ໃຊ້ Flate ເພື່ອເຂົ້າລະຫັດສະຕຣີມທີ່ຍັງບໍ່ໄດ້ເຂົ້າລະຫັດ + In streams that use LZW encoding, use Flate instead ໃຊ້ Flate ແທນ LZW ໃນສະຕຣີມທີ່ເຂົ້າລະຫັດດ້ວຍ LZW Remove invalid links and bookmarks ລຶບລິ້ງຄ໌ ແລະ ບຸກມາກທີ່ໃຊ້ການບໍ່ໄດ້ Remove unreferenced named destinations ລຶບຈຸດໝາຍປາຍທາງທີ່ມີຊື່ເຊິ່ງບໍ່ມີການອ້າງອີງ Compress document structure (object streams) ບີບອັດໂຄງສ້າງເອກະສານ (ສະຕຣີມວັດຖຸ) diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index e2476f196..88d3ae50f 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -1278,6 +1278,7 @@ Update Atnaujinti Use 72 DPI (one point per pixel) Naudoti 72 DPI (vienas taškas vienam pikseliui) Use Flate to encode streams that are not encoded Nekoduotiems srautams koduoti naudoti „Flate“ + In streams that use LZW encoding, use Flate instead LZW koduotiems srautams naudoti „Flate“ Use a Digital ID from a file Naudoti faile esantį skaitmeninį ID Use custom DPI Naudoti pasirinktinį DPI Use each image's embedded DPI; use 72 DPI when it is absent. Naudoti paveikslėlyje įrašytą DPI; jei jo nėra, naudoti 72 DPI. diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 27c1383ec..e8d4a02c7 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -1228,6 +1228,7 @@ Update Oppdater Use 72 DPI (one point per pixel) Bruk 72 DPI (ett punkt per piksel) Use Flate to encode streams that are not encoded Bruk Flate til å kode strømmer som ikke er kodet + In streams that use LZW encoding, use Flate instead Bruk Flate i stedet for LZW i strømmer som er LZW-kodet Use a Digital ID from a file Bruk en digital ID fra en fil Use custom DPI Bruk egendefinert DPI Use each image's embedded DPI; use 72 DPI when it is absent. Bruk hvert bildes innebygde DPI; bruk 72 DPI når den mangler. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6836f3e0a..cf5ef5784 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -606,6 +606,7 @@ Discard private data of other applications Usuń prywatne dane innych aplikacji Discard hidden text Usuń ukryty tekst Use Flate to encode streams that are not encoded Zakoduj nieskompresowane strumienie metodą Flate + In streams that use LZW encoding, use Flate instead Zakoduj strumienie LZW metodą Flate Remove invalid links and bookmarks Usuń nieprawidłowe łącza i zakładki Remove unreferenced named destinations Usuń nieużywane nazwane miejsca docelowe Compress document structure (object streams) Kompresuj strukturę dokumentu (strumienie obiektów) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dcbebae95..55b1df669 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -238,6 +238,7 @@ Discard private data of other applications Descartar dados privados de outros aplicativos Discard hidden text Descartar texto oculto Use Flate to encode streams that are not encoded Usar Flate para codificar fluxos não codificados + In streams that use LZW encoding, use Flate instead Em fluxos que usam codificação LZW, usar Flate Remove invalid links and bookmarks Remover links e marcadores inválidos Remove unreferenced named destinations Remover destinos nomeados não referenciados Compress document structure (object streams) Compactar estrutura do documento (fluxos de objetos) diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 0003e3851..76341ce35 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -1228,6 +1228,7 @@ Update Actualizare Use 72 DPI (one point per pixel) Folosire 72 DPI (un punct pe pixel) Use Flate to encode streams that are not encoded Folosire Flate pentru codarea fluxurilor necodate + In streams that use LZW encoding, use Flate instead Folosire Flate în locul LZW pentru fluxurile codate LZW Use a Digital ID from a file Folosire ID digital dintr-un fișier Use custom DPI Folosire DPI personalizat Use each image's embedded DPI; use 72 DPI when it is absent. Folosește DPI-ul încorporat al fiecărei imagini; 72 DPI când lipsește. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0dc40fdba..6d0daae24 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -604,6 +604,7 @@ Discard private data of other applications Удалить личные данные других приложений Discard hidden text Удалить скрытый текст Use Flate to encode streams that are not encoded Использовать Flate для кодирования некодированных потоков + In streams that use LZW encoding, use Flate instead Использовать Flate вместо LZW в потоках с кодированием LZW Remove invalid links and bookmarks Удалить недействительные ссылки и закладки Remove unreferenced named destinations Удалить неиспользуемые именованные места назначения Compress document structure (object streams) Сжать структуру документа (потоки объектов) diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index f5e374ae7..6df01e98c 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1964,6 +1964,7 @@ Update watermark… Posodobi vodni žig… Use 72 DPI (one point per pixel) Uporabi 72 DPI (ena točka na slikovno piko) Use Flate to encode streams that are not encoded Za kodiranje nekodiranih tokov uporabi Flate + In streams that use LZW encoding, use Flate instead V tokovih s kodiranjem LZW uporabi Flate Use JavaScript Uporabi JavaScript Use a Digital ID from a file Uporabi digitalno identiteto iz datoteke Use a certificate Uporabi potrdilo diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index c93efee5a..896264c96 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -1910,6 +1910,7 @@ Update watermark… Përditëso filigranin… Use 72 DPI (one point per pixel) Përdor 72 DPI (një pikë për piksel) Use Flate to encode streams that are not encoded Përdor Flate për të koduar rrjedhat që nuk janë të koduara + In streams that use LZW encoding, use Flate instead Në rrjedhat që përdorin kodimin LZW, përdor Flate në vend të tij Use JavaScript Përdor JavaScript Use a Digital ID from a file Përdor një ID dixhitale nga një skedar Use a certificate Përdor një certifikatë diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index fa3f6cec4..b48c9b52c 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -588,6 +588,7 @@ comment menu Edit సవరించు Discard private data of other applications ఇతర అప్లికేషన్‌ల ప్రైవేట్ డేటాను విస్మరించు Discard hidden text దాచిన టెక్స్ట్‌ను విస్మరించు Use Flate to encode streams that are not encoded ఎన్‌కోడ్ చేయని స్ట్రీమ్‌లను Flateతో ఎన్‌కోడ్ చేయి + In streams that use LZW encoding, use Flate instead LZW ఎన్‌కోడింగ్ ఉన్న స్ట్రీమ్‌లలో బదులుగా Flate ఉపయోగించు Remove invalid links and bookmarks చెల్లని లింక్‌లు, బుక్‌మార్క్‌లను తీసివేయి Remove unreferenced named destinations ఎక్కడా సూచించని పేరున్న గమ్యాలను తీసివేయి Compress document structure (object streams) పత్రం నిర్మాణాన్ని కుదించు (ఆబ్జెక్ట్ స్ట్రీమ్‌లు) diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index e56770757..a61546507 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -568,6 +568,7 @@ Discard private data of other applications ละทิ้งข้อมูลเฉพาะของแอปพลิเคชันอื่น Discard hidden text ละทิ้งข้อความที่ซ่อนอยู่ Use Flate to encode streams that are not encoded ใช้ Flate เข้ารหัสสตรีมที่ยังไม่ได้เข้ารหัส + In streams that use LZW encoding, use Flate instead ใช้ Flate แทนในสตรีมที่เข้ารหัสแบบ LZW Remove invalid links and bookmarks เอาลิงก์และบุ๊กมาร์กที่ใช้ไม่ได้ออก Remove unreferenced named destinations เอาปลายทางที่มีชื่อซึ่งไม่ถูกอ้างถึงออก Compress document structure (object streams) บีบอัดโครงสร้างเอกสาร (object streams) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c292b8863..8be0c67aa 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -569,6 +569,7 @@ Discard private data of other applications Diğer uygulamaların özel verilerini at Discard hidden text Gizli metni at Use Flate to encode streams that are not encoded Kodlanmamış akışları kodlamak için Flate kullan + In streams that use LZW encoding, use Flate instead LZW kodlamalı akışlarda bunun yerine Flate kullan Remove invalid links and bookmarks Geçersiz bağlantıları ve yer işaretlerini kaldır Remove unreferenced named destinations Referans verilmeyen adlandırılmış hedefleri kaldır Compress document structure (object streams) Belge yapısını sıkıştır (nesne akışları) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cc8f45765..f7cd48a1c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -609,6 +609,7 @@ Discard private data of other applications Видалити приватні дані інших застосунків Discard hidden text Видалити прихований текст Use Flate to encode streams that are not encoded Кодувати незакодовані потоки за допомогою Flate + In streams that use LZW encoding, use Flate instead Використовувати Flate замість LZW у потоках із кодуванням LZW Remove invalid links and bookmarks Видалити недійсні посилання й закладки Remove unreferenced named destinations Видалити іменовані призначення без посилань Compress document structure (object streams) Стиснути структуру документа (потоки об’єктів) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 87abe5129..f9f8026ea 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1268,6 +1268,7 @@ theme Appearance Giao diện Update Cập nhật Use 72 DPI (one point per pixel) Dùng 72 DPI (một điểm cho mỗi điểm ảnh) Use Flate to encode streams that are not encoded Dùng Flate để mã hóa các luồng chưa được mã hóa + In streams that use LZW encoding, use Flate instead Dùng Flate thay cho LZW trong các luồng mã hóa LZW Use a Digital ID from a file Dùng Digital ID từ một tệp Use custom DPI Dùng DPI tùy chỉnh Use each image's embedded DPI; use 72 DPI when it is absent. Dùng DPI nhúng của từng hình ảnh; dùng 72 DPI khi không có. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 594f68df8..ce1f6fc55 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -288,6 +288,7 @@ Discard private data of other applications 丢弃其他应用程序的私有数据 Discard hidden text 丢弃隐藏文本 Use Flate to encode streams that are not encoded 使用 Flate 编码未编码的流 + In streams that use LZW encoding, use Flate instead 在使用 LZW 编码的流中改用 Flate Remove invalid links and bookmarks 删除无效链接和书签 Remove unreferenced named destinations 删除未引用的命名目标 Compress document structure (object streams) 压缩文档结构(对象流) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb578ba26..6828fb7c2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -244,6 +244,7 @@ Discard private data of other applications 捨棄其他應用程式的私有資料 Discard hidden text 捨棄隱藏文字 Use Flate to encode streams that are not encoded 使用 Flate 編碼未編碼的流 + In streams that use LZW encoding, use Flate instead 在使用 LZW 編碼的流中改用 Flate Remove invalid links and bookmarks 刪除無效連結和書籤 Remove unreferenced named destinations 刪除未引用的命名目標 Compress document structure (object streams) 壓縮文件結構(物件流) diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 34188e184..362811f26 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -219,6 +219,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo } OptimizeTab::CleanUp => { ui.add(widgets::checkbox(&mut s.flate_unencoded, tl!("Use Flate to encode streams that are not encoded"))); + ui.add(widgets::checkbox(&mut s.lzw_to_flate, tl!("In streams that use LZW encoding, use Flate instead"))); 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)"))); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 45dda3982..1d49e9ac7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7589,8 +7589,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::discards_and_clean_up"] -notes = "unencoded streams; LZW not yet re-encoded" +evidence = ["crates/optimize/tests/optimize.rs::discards_and_clean_up", "crates/optimize/tests/optimize.rs::lzw_streams_become_flate_and_decode_to_the_same_bytes", "crates/optimize/tests/optimize.rs::a_page_drawn_from_lzw_streams_renders_the_same_after_conversion"] +notes = "Two Clean Up options, as in Acrobat: unencoded streams are Flate-compressed, and LZW streams (content and image XObjects; EarlyChange 0 and 1, with or without a predictor, LZW anywhere in a filter chain) are decoded and stored with Flate instead, keeping the predictor and the filters after the LZW one; LZW data that doesn't decode cleanly is left as it is. Not yet: LZW inline images inside content streams." [[feature]] id = "optimize.remove-invalid-links" From 663368b38d9713aa5340019f4810abc3d2fa0151 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:38:46 +1100 Subject: [PATCH 095/254] fix(ui): the added-text editor keeps the box's alignment Opening a centred or right-aligned Add text box drew its text at the left edge, a layout the box never had, and Ctrl+Z inside the box (which goes to the text, not the document) left it showing that way. The editor now lays the text out with the box's alignment. Justified text is still shown left-aligned while editing (TextEdit can't justify). --- crates/ui-egui/src/content_ui.rs | 8 ++++++++ crates/ui-egui/tests/it/content.rs | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 674210529..f8db4c8ca 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -560,6 +560,13 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8); + // Laid out as the page draws it, so opening the box doesn't show it realigned. + // shortcut: justified text is shown left-aligned here (TextEdit can't justify), until the editor gets its own layouter. + let halign = match t.style.align { + pdfcraft_engine::TextAlign::Center => egui::Align::Center, + pdfcraft_engine::TextAlign::Right => egui::Align::Max, + pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => egui::Align::Min, + }; ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing.x = 4.0; let resp = ui.add( @@ -567,6 +574,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad .font(font) .desired_width(r.width().max(60.0)) .desired_rows(1) + .horizontal_align(halign) .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) .text_color(Color32::from_rgb(cr, cg, cb)) .hint_text(tl!("Type text")) diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 515e3e04f..de06d44b1 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -715,6 +715,28 @@ fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() { } /// Where a mode bar tab is, by its label. +/// Opening right-aligned added text to edit it shows it right-aligned, as the page does, not at +/// the box's left edge, where it never was. +#[test] +fn the_text_editor_keeps_the_boxs_alignment() { + let _gpu = gpu(); + let mut h = harness(); + let rect = [40.0, 195.0, 240.0, 210.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Hi".into(), align: pdfcraft_engine::TextAlign::Right, ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 200.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.run_steps(3); + let r = item_on_screen(&h, rect); + let pixels = h.render().unwrap(); + let left = ink(&pixels, egui::Rect::from_min_max(r.min, pos2(r.center().x, r.max.y))); + let right = ink(&pixels, egui::Rect::from_min_max(pos2(r.center().x, r.min.y), r.max)); + assert!(left == 0 && right > 20, "the text is drawn at the right ({left} dark pixels left, {right} right)"); +} + fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") } From b1795b9d7b8ea818ec192d1b2478af06d378b70f Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:23:58 +1100 Subject: [PATCH 096/254] fix(ui): a narrow added-text box keeps its text at its own edge The editor is at least 60 points wide on screen, so a short box can be clicked into. With the box's alignment applied, right-aligned or centred text in a box narrower than that was placed against the wider editor's edge, past the box's right edge. The editor now reaches past a narrow box away from the edge its text keeps, so the text stays where the page draws it. --- crates/ui-egui/src/content_ui.rs | 14 ++++++++++++-- crates/ui-egui/tests/it/content.rs | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index f8db4c8ca..1622273e3 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -537,6 +537,16 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad } let r = screen_rect(&xf, info, page, draft.rect); let source_rect = draft.index.map(|_| screen_rect(&xf, info, page, draft.style.rect)); + // At least 60 points wide, so a short box can still be clicked into. Where that is wider than + // the box, the editor reaches past it away from the edge its text keeps (the right edge for + // right-aligned text, both halves for centred), so the text stays where the page draws it. + let width = r.width().max(60.0); + let anchor = match draft.style.align { + pdfcraft_engine::TextAlign::Center => 0.5, + pdfcraft_engine::TextAlign::Right => 1.0, + pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => 0.0, + }; + let at = r.min - egui::vec2((width - r.width()) * anchor, 0.0); let background_ready = draft.index.is_some_and(|index| view.text_background.ready_added(page, index)); let tokens = Tokens::get(ctx); let (mut commit, mut cancel, mut done) = (false, false, false); @@ -548,7 +558,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad egui::Area::new(egui::Id::new(("added-text", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) - .fixed_pos(r.min) + .fixed_pos(at) .constrain_to(view.viewport_rect()) .constrain(false) .show(ctx, |ui| { @@ -572,7 +582,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad let resp = ui.add( egui::TextEdit::multiline(&mut t.text) .font(font) - .desired_width(r.width().max(60.0)) + .desired_width(width) .desired_rows(1) .horizontal_align(halign) .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index de06d44b1..add686846 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -737,6 +737,29 @@ fn the_text_editor_keeps_the_boxs_alignment() { assert!(left == 0 && right > 20, "the text is drawn at the right ({left} dark pixels left, {right} right)"); } +/// A box narrower than the editor's least width (60 points on screen): right-aligned text stays +/// at the box's right edge, not the wider editor's, which lies past it. +#[test] +fn the_text_editor_aligns_a_narrow_box_to_its_own_edge() { + let _gpu = gpu(); + let mut h = harness(); + let rect = [40.0, 195.0, 60.0, 210.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Hi".into(), align: pdfcraft_engine::TextAlign::Right, ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 50.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.run_steps(3); + let r = item_on_screen(&h, rect); + assert!(r.width() < 60.0, "the box is narrower than the editor: {}", r.width()); + let pixels = h.render().unwrap(); + let inside = ink(&pixels, r); + let past = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() + 2.0, r.min.y), pos2(r.right() + 40.0, r.max.y))); + assert!(inside > 20 && past == 0, "the text is drawn in its box ({inside} dark pixels in it, {past} past its right edge)"); +} + fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") } From 941077715ae1c21425846381642bddb27a98518e Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:38:58 +0800 Subject: [PATCH 097/254] fix(forms): write each /DR font as its own object, so Acrobat keeps typed text (#3441) A text field PdfCraft added showed its typed text in Acrobat only while the field had focus: as soon as the user clicked elsewhere the text vanished (it was still stored, and printed). PDF-XChange Editor and PDFgear were fine, and the same field made by MuPDF was fine in Acrobat. Bisected in Acrobat with one-change variants of the same file: only the variants whose /DR fonts are objects (or that have no /DR) keep the text. The appearance stream's own fonts, /P, the appearance layout and /DA 0 made no difference. PdfCraft wrote Helv, TiRo and Cour inline in /DR. ensure_form now adds them as objects, and turns fonts an earlier save wrote inline into objects too (same dictionaries). An indirect /DR or /Font stays the object it is. The XFA-to-AcroForm writer does the same. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/author.rs | 25 ++++- crates/forms/src/tests.rs | 219 +++++++++++++++++++++++++++++++++++++ crates/xfa/src/pdf.rs | 7 ++ crates/xfa/src/tests.rs | 44 +++++++- 4 files changed, 290 insertions(+), 5 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 544d0a7f8..dc36de729 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -136,8 +136,17 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result { } let mut dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let mut fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - // Acrobat's resource names for the standard fonts its Appearance tab offers. + // Each /DR font is its own object: Acrobat, redrawing a text field after it is typed in, + // shows nothing when the font is a dictionary written inline in /DR (#3441). Fonts an + // earlier save wrote inline become objects too; what they say is unchanged. let mut added = false; + let inline: Vec<(Vec, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); + for (name, font) in inline { + let r = doc.add(Object::Dict(font)); + fonts.set(name, Object::Ref(r)); + added = true; + } + // Acrobat's resource names for the standard fonts its Appearance tab offers. for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { if fonts.contains(name.as_bytes()) { continue; @@ -147,12 +156,20 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result { f.set(b"Subtype".to_vec(), Object::name("Type1")); f.set(b"BaseFont".to_vec(), Object::name(base)); f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - fonts.set(name.as_bytes().to_vec(), Object::Dict(f)); + let r = doc.add(Object::Dict(f)); + fonts.set(name.as_bytes().to_vec(), Object::Ref(r)); added = true; } if added { - dr.set(b"Font".to_vec(), Object::Dict(fonts)); - af.set(b"DR".to_vec(), Object::Dict(dr)); + // An indirect /DR or /Font stays the object it is. + match dr.get(b"Font").and_then(Object::as_ref) { + Some(fr) => doc.set(fr, Object::Dict(fonts)), + None => dr.set(b"Font".to_vec(), Object::Dict(fonts)), + } + match af.get(b"DR").and_then(Object::as_ref) { + Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)), + None => af.set(b"DR".to_vec(), Object::Dict(dr)), + } } doc.set(r, Object::Dict(af)); Ok(r) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 05b14d933..601dc7014 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3073,3 +3073,222 @@ fn a_skipped_field_leaves_its_widget_to_the_next() { let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect(); assert_eq!(widgets, [("b", 1)]); } + +/// The form's `/DR /Font` entries, resolved through an indirect `/DR` or `/Font`. +fn dr_fonts(doc: &Document) -> Dict { + let af = acroform(doc).unwrap(); + let dr = doc.resolve(af.get(b"DR").unwrap()); + doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap()).as_dict().cloned().unwrap() +} + +/// #3441: Acrobat shows nothing in a text field it redraws after typing when the field's `/DR` +/// font is a dictionary written inline. A form PdfCraft makes holds each font as its own object. +#[test] +fn a_new_form_holds_its_fonts_as_objects() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { + let r = fonts.reference(name.as_bytes()).unwrap_or_else(|| panic!("{name} is an object: {fonts:?}")); + assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont")), Some(base.as_bytes())); + } +} + +/// #3441: a form saved earlier with inline `/DR` fonts gets them as objects once a field is +/// added, saying the same; an indirect `/Font` dictionary stays the object it was. +#[test] +fn inline_form_fonts_become_objects_and_an_indirect_font_dictionary_stays_one() { + for indirect in [false, true] { + let font = "/Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> /Mine << /Type /Font /Subtype /Type1 /BaseFont /Symbol >>"; + let (fonts_entry, extra) = + if indirect { ("6 0 R".to_string(), vec![format!("<< {font} >>")]) } else { (format!("<< {font} >>"), Vec::new()) }; + let mut objs: Vec = vec![ + "<< /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 >>".into(), + format!("<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font {fonts_entry} >> >>"), + "null".into(), + ]; + objs.extend(extra); + let mut doc = document(&objs); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for (name, base) in [("Helv", "Helvetica"), ("Mine", "Symbol"), ("TiRo", "Times-Roman")] { + let r = fonts.reference(name.as_bytes()).unwrap_or_else(|| panic!("{name} is an object (indirect {indirect}): {fonts:?}")); + assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont")), Some(base.as_bytes()), "{name} says the same"); + } + let dr = doc.resolve(acroform(&doc).unwrap().get(b"DR").unwrap()); + assert_eq!(dr.as_dict().unwrap().reference(b"Font").is_some(), indirect, "the /Font dictionary keeps its form"); + } +} + +/// #3441: an indirect `/DR` stays the object it is, and a form whose three standard fonts are +/// all already there (inline) still gets them as objects, though none is added. +#[test] +fn an_indirect_resource_dictionary_and_a_complete_inline_set_are_handled() { + let inline = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + let all = format!( + "/Helv {inline} /TiRo << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> /Cour << /Type /Font /Subtype /Type1 /BaseFont /Courier >>" + ); + for indirect_dr in [false, true] { + let dr = if indirect_dr { "5 0 R".to_string() } else { format!("<< /Font << {all} >> >>") }; + 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 >>".into(), + format!("<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR {dr} >>"), + format!("<< /Font << {all} >> >>"), + ]); + let mut doc = doc; + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.reference(name.as_bytes()).is_some(), "{name} is an object (indirect /DR {indirect_dr}): {fonts:?}"); + } + assert_eq!(acroform(&doc).unwrap().reference(b"DR").is_some(), indirect_dr, "/DR keeps its form"); + } +} + +/// #3441: fonts the form already has are its own, not replaced by the standard ones: an inline +/// `/Helv` that is Arial becomes an object that is still Arial, a `/TiRo` object stays that +/// object, and a form needing no change leaves its `/DR` object unwritten. The fonts PdfCraft +/// adds use WinAnsi, as Acrobat's do. +#[test] +fn existing_form_fonts_are_kept_and_an_unchanged_resource_dictionary_is_not_rewritten() { + let mut 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 >>".into(), + "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Arial >> /TiRo 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>".into(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + let font = |name: &str| doc.get(fonts.reference(name.as_bytes()).unwrap()).as_dict().cloned().unwrap(); + assert_eq!(font("Helv").name(b"BaseFont"), Some(&b"Arial"[..]), "the form's own Helv"); + assert_eq!(fonts.reference(b"TiRo"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "the same object"); + assert_eq!(font("Cour").name(b"Encoding"), Some(&b"WinAnsiEncoding"[..])); + + // Every font an object already, /DR an object: adding a field leaves /DR as it was. + let mut 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 >>".into(), + "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR 5 0 R >>".into(), + "<< /Font << /Helv 6 0 R /TiRo 6 0 R /Cour 6 0 R >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + assert!(!doc.modified_objects().contains(&5), "/DR unchanged: {:?}", doc.modified_objects()); +} + +/// #3441, exhaustively: every way a form's `/DR` may hold its fonts. `/DR` missing, inline or an +/// object; its `/Font` missing, inline, an object or not a dictionary; `/Helv` missing, inline, +/// an object, a reference to nothing, `null` or a number; and a font of the form's own, inline or +/// an object. After a field is added, whatever the shape: +/// - no font is a dictionary written inline; +/// - a font that was an object is the same object, and one that was inline says the same as an +/// object; +/// - a `/DR` or `/Font` that was an object (and a dictionary) still is one; +/// - an entry that is not a dictionary (`null`, a number, a reference to nothing) is left alone; +/// - Helv, TiRo and Cour are there. +#[test] +fn every_shape_of_form_fonts_ends_as_objects() { + let font = |base: &str| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>"); + let mut cases = 0; + for dr_shape in ["missing", "inline", "object"] { + for font_shape in ["missing", "inline", "object", "number"] { + for helv in ["missing", "inline", "object", "dangling", "null", "number"] { + for mine in ["missing", "inline", "object"] { + if dr_shape == "missing" && (font_shape != "missing" || helv != "missing" || mine != "missing") { + continue; + } + if matches!(font_shape, "missing" | "number") && (helv != "missing" || mine != "missing") { + continue; + } + cases += 1; + let what = format!("/DR {dr_shape}, /Font {font_shape}, /Helv {helv}, /Mine {mine}"); + let mut entries = String::new(); + match helv { + "inline" => entries += &format!("/Helv {} ", font("Arial")), + "object" => entries += "/Helv 7 0 R ", + "dangling" => entries += "/Helv 99 0 R ", + "null" => entries += "/Helv null ", + "number" => entries += "/Helv 3 ", + _ => {} + } + match mine { + "inline" => entries += &format!("/Mine {} ", font("Symbol")), + "object" => entries += "/Mine 8 0 R ", + _ => {} + } + let fonts_value = match font_shape { + "inline" => format!("<< {entries}>>"), + "object" => "6 0 R".into(), + "number" => "7".into(), + _ => String::new(), + }; + let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; + let dr_value = match dr_shape { + "inline" => dr_dict.clone(), + "object" => "5 0 R".into(), + _ => String::new(), + }; + let af = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; + let mut 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 >>".into(), + af, + if dr_shape == "object" { dr_dict } else { "null".into() }, + if font_shape == "object" { format!("<< {entries}>>") } else { "null".into() }, + font("Helvetica-Oblique"), + font("ZapfDingbats"), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) + .unwrap_or_else(|e| panic!("{what}: {e}")); + let doc = reopen(&doc); + let af = acroform(&doc).unwrap(); + let dr = doc.resolve(af.get(b"DR").unwrap_or_else(|| panic!("{what}: no /DR"))); + let fonts = dr_fonts(&doc); + for (name, value) in fonts.iter() { + assert!(value.as_dict().is_none(), "{what}: {} is inline", String::from_utf8_lossy(name)); + } + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.contains(name.as_bytes()), "{what}: no {name}"); + } + let base = + |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); + match helv { + "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{what}"), + "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{what}"), + "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{what}"), + "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{what}"), + "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{what}"), + _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{what}"), + } + match mine { + "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{what}"), + "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{what}"), + _ => assert!(!fonts.contains(b"Mine"), "{what}"), + } + if dr_shape == "object" { + assert_eq!(af.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{what}: /DR stays its object"); + } + if font_shape == "object" { + assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{what}: /Font stays its object"); + } + } + } + } + } + assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried"); +} diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 8e0c47d88..1fcb904e0 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -769,6 +769,13 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result z.set(b"BaseFont".to_vec(), Object::name("ZapfDingbats")); dr_fonts.set(b"ZaDb".to_vec(), Object::Dict(z)); } + // Each font its own object: Acrobat shows nothing in a text field it redraws after typing + // when the field's /DR font is a dictionary written inline (#3441). + let inline: Vec<(Vec, Dict)> = dr_fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); + for (name, font) in inline { + let r = doc.add(Object::Dict(font)); + dr_fonts.set(name, Object::Ref(r)); + } dr.set(b"Font".to_vec(), Object::Dict(dr_fonts)); acro.set(b"DR".to_vec(), Object::Dict(dr)); if !acro.contains(b"DA") { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 411529b17..3ffb115aa 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_cos::{Document, Object, SaveOptions, write_incremental}; +use pdfcraft_cos::{Dict, Document, Object, SaveOptions, write_incremental}; use super::*; use crate::data::{ @@ -324,6 +324,12 @@ fn rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields() { let dr = doc.resolve(acro.get(b"DR").unwrap()); let fonts = doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap()); assert!(fonts.as_dict().unwrap().contains(b"Cour") && fonts.as_dict().unwrap().contains(b"ZaDb")); + // Each one its own object: Acrobat shows nothing in a text field it redraws after typing + // when the /DR font is written inline (#3441). + for (name, font) in fonts.as_dict().unwrap().iter() { + let r = font.as_ref().unwrap_or_else(|| panic!("{} is an object", String::from_utf8_lossy(name))); + assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"Type")), Some(&b"Font"[..])); + } // A field carries its SOM path for data round trips. let first = doc.resolve(&doc.resolve(acro.get(b"Fields").unwrap()).as_array().unwrap()[0]); assert!(first.as_dict().unwrap().contains(SOM_KEY)); @@ -2832,3 +2838,39 @@ fn long_names_repeated_by_an_index_are_capped() { let data = build_data(&doc, &fields); assert!(data.len() < 2_000_000, "{} bytes of data built", data.len()); } + +/// #3441: a font the form already holds as an object stays that object when the XFA layout +/// writes its fields' fonts into `/DR`. +#[test] +fn rendering_keeps_the_forms_own_font_objects() { + let mut doc = Document::open(Arc::new(shell(&template(2)))).unwrap(); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Symbol")); + let mine = doc.add(Object::Dict(font)); + let mut fonts = Dict::new(); + fonts.set(b"Mine".to_vec(), Object::Ref(mine)); + let mut dr = Dict::new(); + dr.set(b"Font".to_vec(), Object::Dict(fonts)); + let root = doc.root().unwrap(); + match doc.get(root).as_dict().unwrap().get(b"AcroForm").cloned().unwrap() { + Object::Ref(af) => doc.update_dict(af, |d| d.set(b"DR".to_vec(), Object::Dict(dr))).unwrap(), + _ => doc + .update_dict(root, |c| { + if let Some(Object::Dict(af)) = c.get(b"AcroForm").cloned() { + let mut af = af; + af.set(b"DR".to_vec(), Object::Dict(dr)); + c.set(b"AcroForm".to_vec(), Object::Dict(af)); + } + }) + .unwrap(), + } + render_into(&mut doc).unwrap(); + let catalog = doc.get(root); + let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap()); + let dr = doc.resolve(acro.as_dict().unwrap().get(b"DR").unwrap()); + let fonts = doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap()); + assert_eq!(fonts.as_dict().unwrap().reference(b"Mine"), Some(mine), "the form's own font object"); + assert!(fonts.as_dict().unwrap().reference(b"Helv").is_some(), "and the layout's fonts as objects"); +} From fa146bbecd7b89514b08c510737ff3d321af9ce2 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:09:43 +0800 Subject: [PATCH 098/254] fix(forms): filling a field also stores inline /DR fonts as objects (#3441) Review follow-up (CodeRabbit on #3488): a form an earlier PdfCraft made should work in Acrobat once it has been filled here too, not only once a field is added or edited. set_value now runs the same conversion (shared with ensure_form through inline_fonts_as_objects and store_form_fonts); it adds nothing, only changes where the existing fonts are stored. Not in a form a signature covers (a signature field with /V): there the file stays as the signer saw it apart from the value. PdfCraft's own change check counts a /DR change after a form-fill certification as form fill, but another validator may not. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/author.rs | 80 ++++++++++++++++++++++++++++---------- crates/forms/src/lib.rs | 1 + crates/forms/src/tests.rs | 49 +++++++++++++++++++++++ 3 files changed, 109 insertions(+), 21 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index dc36de729..4b421c31f 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -134,18 +134,8 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result { if !af.contains(b"DA") { af.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); } - let mut dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let mut fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - // Each /DR font is its own object: Acrobat, redrawing a text field after it is typed in, - // shows nothing when the font is a dictionary written inline in /DR (#3441). Fonts an - // earlier save wrote inline become objects too; what they say is unchanged. - let mut added = false; - let inline: Vec<(Vec, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); - for (name, font) in inline { - let r = doc.add(Object::Dict(font)); - fonts.set(name, Object::Ref(r)); - added = true; - } + let (dr, mut fonts) = form_fonts(doc, &af); + let mut added = inline_fonts_as_objects(doc, &mut fonts); // Acrobat's resource names for the standard fonts its Appearance tab offers. for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { if fonts.contains(name.as_bytes()) { @@ -161,20 +151,68 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result { added = true; } if added { - // An indirect /DR or /Font stays the object it is. - match dr.get(b"Font").and_then(Object::as_ref) { - Some(fr) => doc.set(fr, Object::Dict(fonts)), - None => dr.set(b"Font".to_vec(), Object::Dict(fonts)), - } - match af.get(b"DR").and_then(Object::as_ref) { - Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)), - None => af.set(b"DR".to_vec(), Object::Dict(dr)), - } + store_form_fonts(doc, &mut af, dr, fonts); } doc.set(r, Object::Dict(af)); Ok(r) } +/// The form dictionary's `/DR` and its `/Font` dictionary, resolved (empty where missing). +fn form_fonts(doc: &Document, af: &Dict) -> (Dict, Dict) { + let dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + (dr, fonts) +} + +/// Each /DR font its own object: Acrobat, redrawing a text field after it is typed in, shows +/// nothing when the font is a dictionary written inline in /DR (#3441). Fonts written inline +/// (by an earlier save, or another writer) become objects saying the same; a font that is an +/// object already, or an entry that isn't a dictionary, is left alone. Whether any changed. +fn inline_fonts_as_objects(doc: &mut Document, fonts: &mut Dict) -> bool { + let inline: Vec<(Vec, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); + let changed = !inline.is_empty(); + for (name, font) in inline { + let r = doc.add(Object::Dict(font)); + fonts.set(name, Object::Ref(r)); + } + changed +} + +/// Write `fonts` back as the `/DR /Font` of the form dictionary `af`. An indirect `/DR` or +/// `/Font` stays the object it is. +fn store_form_fonts(doc: &mut Document, af: &mut Dict, mut dr: Dict, fonts: Dict) { + match dr.get(b"Font").and_then(Object::as_ref) { + Some(fr) => doc.set(fr, Object::Dict(fonts)), + None => dr.set(b"Font".to_vec(), Object::Dict(fonts)), + } + match af.get(b"DR").and_then(Object::as_ref) { + Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)), + None => af.set(b"DR".to_vec(), Object::Dict(dr)), + } +} + +/// Filling a field: the form's inline `/DR` fonts become objects (see +/// [`inline_fonts_as_objects`]), so a form an earlier version of PdfCraft made works in Acrobat +/// once it has been filled here too (#3441). Only the font storage changes; nothing is added. +/// Not in a form a signature covers: there the file stays as the signer saw it, apart from the +/// value itself. +pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Result<(), FormError> { + let signed = all.iter().any(|f| f.kind == crate::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V"))); + if signed { + return Ok(()); + } + let Some(root) = doc.root() else { return Ok(()) }; + // A form dictionary that isn't an object of its own is left as it is. + let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"AcroForm")) else { return Ok(()) }; + let Some(mut af) = doc.get(r).as_dict().cloned() else { return Ok(()) }; + let (dr, mut fonts) = form_fonts(doc, &af); + if inline_fonts_as_objects(doc, &mut fonts) { + store_form_fonts(doc, &mut af, dr, fonts); + doc.set(r, Object::Dict(af)); + } + Ok(()) +} + /// The Appearance tab: borders, colours and text. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Look { diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 82c1c8e74..d00d46a69 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -932,6 +932,7 @@ pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, script return Err(FormError::ReadOnly(name.into())); } write_value(doc, f, value, scripts)?; + author::form_fonts_as_objects(doc, &all)?; // Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a // name as separate field dictionaries, instead of widgets under one parent, get the value // the first one took after its scripts, without running them again. diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 601dc7014..6773b5b0b 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3292,3 +3292,52 @@ fn every_shape_of_form_fonts_ends_as_objects() { } assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried"); } + +/// #3441 (review follow-up): filling a field converts the form's inline `/DR` fonts, so a form +/// an earlier PdfCraft made works in Acrobat once it has been filled here. Nothing is added. A +/// form a signature covers keeps `/DR` as the signer saw it, and a form whose fonts are objects +/// already leaves its `/DR` object unwritten. +#[test] +fn filling_a_field_makes_inline_form_fonts_objects_unless_the_form_is_signed() { + let inline = "/Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + let form = |signed: bool, dr: &str| { + let sig = if signed { "<< /Type /Annot /Subtype /Widget /FT /Sig /T (sig) /V 7 0 R /Rect [0 0 0 0] /P 3 0 R >>" } else { "null" }; + let fields = if signed { "[5 0 R 6 0 R]" } else { "[5 0 R]" }; + 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 [5 0 R] >>".into(), + format!("<< /Fields {fields} /DA (/Helv 0 Tf 0 g) /DR {dr} >>"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), + sig.into(), + "<< /Type /Sig /Filter /Adobe.PPKLite /Contents <00> >>".into(), + "<< /Font << /Helv 9 0 R >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]) + }; + let mut doc = form(false, &format!("<< /Font << {inline} >> >>")); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + assert!(fonts.reference(b"Helv").is_some(), "Helv became an object: {fonts:?}"); + assert_eq!(fonts.len(), 1, "nothing added: {fonts:?}"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); + + let mut doc = form(true, &format!("<< /Font << {inline} >> >>")); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + assert!(dr_fonts(&doc).get(b"Helv").is_some_and(|h| h.as_dict().is_some()), "a signed form keeps /DR as it was"); + + // An empty signature field signs nothing: the form is converted as an unsigned one. + let mut doc = form(true, &format!("<< /Font << {inline} >> >>")); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + d.remove(b"V"); + }) + .unwrap(); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + assert!(dr_fonts(&reopen(&doc)).reference(b"Helv").is_some(), "an unsigned signature field doesn't hold /DR back"); + + let mut doc = form(false, "8 0 R"); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + assert!(!doc.modified_objects().contains(&8), "/DR unchanged: {:?}", doc.modified_objects()); +} From 950715da0dd311148c849e67194622dbf5a8537d Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:23:53 +1100 Subject: [PATCH 099/254] test(ui): the narrow-box tests check the text reaches the box's right edge --- crates/ui-egui/tests/it/content.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index add686846..619352b8f 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -756,8 +756,12 @@ fn the_text_editor_aligns_a_narrow_box_to_its_own_edge() { assert!(r.width() < 60.0, "the box is narrower than the editor: {}", r.width()); let pixels = h.render().unwrap(); let inside = ink(&pixels, r); + let edge = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() - r.width() * 0.25, r.min.y), r.max)); let past = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() + 2.0, r.min.y), pos2(r.right() + 40.0, r.max.y))); - assert!(inside > 20 && past == 0, "the text is drawn in its box ({inside} dark pixels in it, {past} past its right edge)"); + assert!( + inside > 20 && edge > 0 && past == 0, + "the text is drawn in its box, up to its right edge ({inside} dark pixels in it, {edge} in its last quarter, {past} past it)" + ); } fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { From e9a5f9ca05c667fef70008795343a02c1bd0322f Mon Sep 17 00:00:00 2001 From: mmmaarian03-crypto <253881360+mmmaarian03-crypto@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:54:26 +0300 Subject: [PATCH 100/254] OCR: a recognizer panic or a dead worker ends the run (#3919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recognize text (one document) and Recognize text in multiple files ran their worker unguarded. A panic in the recognizer (third-party inference) or a worker that died holding the progress lock left the run in place: the progress notice stayed, and every later run was refused with "Text recognition is already running" until restart. - Both workers run inside `pdfcraft_engine::guard`; a panic becomes the run's error ("Recognize text failed: …", in the UI's language). - `poll_ocr` treats a poisoned progress lock as a failure: it clears the run or batch and says so. The batch's loop moved unchanged into `recognize_files` so it can run inside the guard. A thread that can't be spawned is #1871's (#1819), and Export and the Action Wizard are #4043's; this leaves both alone. Tests (crates/ui-egui/src/ocr_ui.rs): a panicking recognizer ends with the error; a page run and a batch whose worker died holding the lock are cleared with a notice. Both fail without the change. The OCR integration tests pass with the real models (PDFCRAFT_MODELS). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/ocr_ui.rs | 185 ++++++++++++++++++++++++++--------- 1 file changed, 138 insertions(+), 47 deletions(-) diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index a10cb0b0a..7f60be35d 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -168,13 +168,18 @@ impl PdfCraftApp { 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(); + // A failure is written on the worker thread: in the UI's language. + let lang = crate::i18n::current(); let work = move || { - let result = pdfcraft_engine::ocr::engine().map(|ocr| { - job.run(&ocr, |done, total| { - let Ok(mut s) = p.lock() else { return false }; - s.done = done; - s.total = total; - !s.cancel + crate::i18n::set_current(lang); + let result = recognize_guarded(|| { + pdfcraft_engine::ocr::engine().map(|ocr| { + job.run(&ocr, |done, total| { + let Ok(mut s) = p.lock() else { return false }; + s.done = done; + s.total = total; + !s.cancel + }) }) }); if let Ok(mut s) = p.lock() { @@ -227,44 +232,15 @@ impl PdfCraftApp { let lang = crate::i18n::current(); let work = move || { crate::i18n::set_current(lang); - let (mut ok, mut words, mut failed) = (0, 0, Vec::new()); - match pdfcraft_engine::ocr::engine() { - Err(e) => failed.push(e), - Ok(ocr) => { - for (i, (name, bytes)) in files.into_iter().enumerate() { - if let Ok(mut s) = p.lock() { - s.done = i; - } - let r = pdfcraft_engine::ocr::recognize_file(&name, Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true); - let saved = r.and_then(|r| { - #[cfg(not(target_arch = "wasm32"))] - crate::editing::write_atomically(&dir.join(&name).to_string_lossy(), &r.bytes).map_err(|e| e.to_string())?; - #[cfg(target_arch = "wasm32")] - crate::editing::download(&name, &r.bytes)?; - Ok(r.words()) - }); - match saved { - Ok(n) => { - ok += 1; - words += n; - } - Err(e) => failed.push(format!("{name}: {}", e)), - } - } - } - } - let done = if ok == 1 { - crate::i18n::fmt(tl!("Recognized {w} words in 1 file"), &[("w", &words.to_string())]) - } else { - crate::i18n::fmt(tl!("Recognized {w} words in {n} files"), &[("w", &words.to_string()), ("n", &ok.to_string())]) - }; - let mut msg = done; - if !failed.is_empty() { - msg.push_str(&crate::i18n::fmt(tl!("; failed: {list}"), &[("list", &failed.join("; "))])); - } + // A panic in the recognizer still ends the batch with a summary (#3919). + #[cfg(not(target_arch = "wasm32"))] + let summary = pdfcraft_engine::guard(|| recognize_files(files, &settings, &p, &dir)); + #[cfg(target_arch = "wasm32")] + let summary = pdfcraft_engine::guard(|| recognize_files(files, &settings, &p)); + let summary = summary.unwrap_or_else(|panic| failed(&panic)); if let Ok(mut s) = p.lock() { s.done = s.total; - s.message = Some(msg); + s.message = Some(summary); } }; #[cfg(not(target_arch = "wasm32"))] @@ -278,7 +254,54 @@ impl PdfCraftApp { self.ocr_batch = Some(progress); self.poll_ocr(); } +} +/// Recognize each file and write its searchable copy into `dir`; returns the summary to show. +fn recognize_files( + files: Vec<(String, Vec)>, + settings: &OcrSettings, + p: &Mutex, + #[cfg(not(target_arch = "wasm32"))] dir: &std::path::Path, +) -> String { + let (mut ok, mut words, mut failed) = (0, 0, Vec::new()); + match pdfcraft_engine::ocr::engine() { + Err(e) => failed.push(e), + Ok(ocr) => { + for (i, (name, bytes)) in files.into_iter().enumerate() { + if let Ok(mut s) = p.lock() { + s.done = i; + } + let r = pdfcraft_engine::ocr::recognize_file(&name, Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true); + let saved = r.and_then(|r| { + #[cfg(not(target_arch = "wasm32"))] + crate::editing::write_atomically(&dir.join(&name).to_string_lossy(), &r.bytes).map_err(|e| e.to_string())?; + #[cfg(target_arch = "wasm32")] + crate::editing::download(&name, &r.bytes)?; + Ok(r.words()) + }); + match saved { + Ok(n) => { + ok += 1; + words += n; + } + Err(e) => failed.push(format!("{name}: {}", e)), + } + } + } + } + let done = if ok == 1 { + crate::i18n::fmt(tl!("Recognized {w} words in 1 file"), &[("w", &words.to_string())]) + } else { + crate::i18n::fmt(tl!("Recognized {w} words in {n} files"), &[("w", &words.to_string()), ("n", &ok.to_string())]) + }; + let mut msg = done; + if !failed.is_empty() { + msg.push_str(&crate::i18n::fmt(tl!("; failed: {list}"), &[("list", &failed.join("; "))])); + } + msg +} + +impl PdfCraftApp { /// Stop a running recognition (the pages read so far are kept). pub fn cancel_ocr(&mut self) { if let Some(r) = &self.ocr_run @@ -291,13 +314,13 @@ impl PdfCraftApp { /// Show progress; apply the result once the worker is done. pub(crate) fn poll_ocr(&mut self) { if let Some(b) = self.ocr_batch.clone() { - let msg = b.lock().ok().map(|mut s| s.message.take().ok_or((s.done, s.total))); + let msg = b.lock().map(|mut s| s.message.take().ok_or((s.done, s.total))).map_err(|e| e.to_string()); match msg { - Some(Ok(m)) => { + Ok(Ok(m)) => { self.ocr_batch = None; self.notify(m); } - Some(Err((done, total))) => { + Ok(Err((done, total))) => { let m = crate::i18n::fmt( tl!("Recognizing text… file {d} of {t}"), &[("d", &(done + 1).min(total.max(1)).to_string()), ("t", &total.max(1).to_string())], @@ -309,12 +332,25 @@ impl PdfCraftApp { ctx.request_repaint_after(std::time::Duration::from_millis(200)); } } - None => self.ocr_batch = None, + // The worker stopped while holding the lock: it will never report back (#3919). + Err(e) => { + self.ocr_batch = None; + self.notify(failed(&e)); + } } } let Some(run) = self.ocr_run.as_ref() else { return }; let (doc, progress) = (run.doc, run.progress.clone()); - let Ok(mut s) = progress.lock() else { return }; + let mut s = match progress.lock() { + Ok(s) => s, + Err(e) => { + // As above: without this the run never clears and refuses every later one. + let e = e.to_string(); + self.ocr_run = None; + self.notify(failed(&e)); + return; + } + }; let Some(result) = s.result.take() else { let msg = crate::i18n::fmt( tl!("Recognizing text… page {d} of {t}"), @@ -367,3 +403,58 @@ impl PdfCraftApp { } } } + +/// "Recognize text failed: {e}", in the current language. +fn failed(e: &str) -> String { + crate::i18n::fmt(tl!("{label} failed: {e}"), &[("label", tl!("Recognize text")), ("e", e)]) +} + +/// `recognize`, with a panic in it (a damaged model, a page the recognizer can't take) turned into +/// the run's error: the run has to end either way, or it refuses every later one (#3919). +fn recognize_guarded(recognize: impl FnOnce() -> Result) -> Result { + pdfcraft_engine::guard(recognize).unwrap_or_else(|panic| Err(failed(&panic))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_panicking_recognizer_ends_the_run_with_an_error() { + let error = recognize_guarded(|| -> Result<(), String> { panic!("the model file is damaged") }).unwrap_err(); + assert!(error.starts_with("Recognize text failed") && error.contains("the model file is damaged"), "{error}"); + assert_eq!(recognize_guarded(|| Ok::<_, String>(7)), Ok(7)); + } + + /// Poison `m` the way a worker that panics while it holds the lock does. + fn poison(m: &Arc>) { + let worker = m.clone(); + let _ = std::thread::spawn(move || { + let _held = worker.lock(); + panic!("the worker died"); + }) + .join(); + assert!(m.is_poisoned()); + } + + #[test] + fn a_run_whose_worker_died_ends_instead_of_waiting_forever() { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let progress = Arc::new(Mutex::new(OcrProgress { total: 3, ..Default::default() })); + poison(&progress); + app.ocr_run = Some(OcrRun { doc: DocId(1), progress }); + app.poll_ocr(); + assert!(app.ocr_run.is_none(), "the next run isn't refused as already running"); + let toast = app.toast.take().map(|t| t.0).unwrap_or_default(); + assert!(toast.starts_with("Recognize text failed"), "{toast}"); + + let batch = Arc::new(Mutex::new(BatchProgress { total: 2, ..Default::default() })); + poison(&batch); + app.ocr_batch = Some(batch); + app.poll_ocr(); + assert!(app.ocr_batch.is_none()); + let toast = app.toast.take().map(|t| t.0).unwrap_or_default(); + assert!(toast.starts_with("Recognize text failed"), "{toast}"); + } +} From d6ba2413ba556894c67f89019f4da27f1cdd6950 Mon Sep 17 00:00:00 2001 From: mmmaarian03-crypto <253881360+mmmaarian03-crypto@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:37:35 +0300 Subject: [PATCH 101/254] Comments: the composer hides while its comment is scrolled away (#4021) The floating comment card (new note, text box, edit comment) was an egui Area constrained to the whole window. When the page scrolled its comment under the mode bar, the card was pushed back into the window and sat over the mode bar and toolbar until the whole page left the screen. It now shows only while its anchor is inside the document view, and is kept inside that view. While the comment is out of sight the draft is kept, and the editor takes the focus again when it comes back. Test: tests/comments.rs the_composer_hides_while_its_comment_is_scrolled_away scrolls a sticky note's anchor above the document with its page still on screen; it fails without the change (the card sat at y=43, over the toolbar, with the document starting at y=134) and passes with it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/comments.rs | 10 +++++++++- crates/ui-egui/tests/it/comments.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index d80f988b7..df2be3780 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1564,6 +1564,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, }; let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs, allowed: true, hidden: false, redact_mark: ([0.89, 0.13, 0.13], 1.0) }; let anchor = cx.to_screen(c.at); + let viewport = view.viewport_rect(); let t = Tokens::get(ctx); let mut post = false; let mut cancel = false; @@ -1576,8 +1577,15 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ComposerKind::Replace => "Replacement text", ComposerKind::Edit(_) => "Edit comment", }; + // Its comment scrolled out of the document: keep the draft until it is back, rather than + // pushing the card into the window over the mode bar and toolbar (#4021). + if !viewport.contains(anchor) { + c.focus = true; + return None; + } let pos = pos2(anchor.x + 12.0, anchor.y); - egui::Area::new(egui::Id::new(("comment-composer", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + let area = egui::Area::new(egui::Id::new(("comment-composer", view.id.0))).order(egui::Order::Foreground).constrain_to(viewport); + area.fixed_pos(pos).show(ctx, |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(12)).corner_radius(CornerRadius::same(8)).show(ui, |ui| { ui.set_width(260.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 6194c7516..336f7d215 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -675,6 +675,34 @@ fn sticky_note_composer_posts_and_returns_to_select() { assert_eq!(comments(&h).len(), 1); } +/// The composer leaves with its comment when the page scrolls it out of the document, and comes +/// back with the draft (#4021). It used to be pushed back into the window, over the mode bar and +/// the toolbar, while its comment was out of sight. +#[test] +fn the_composer_hides_while_its_comment_is_scrolled_away() { + let mut h = harness(|app| app.set_option("quick", "note").unwrap()); + click_pt(&mut h, (250.0, 180.0)); + field(&h, "Add a comment").type_text("Draft"); + h.run_steps(2); + let editors = |h: &Harness<'static, PdfCraftApp>| -> Vec { + h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput).map(|n| n.rect()).collect() + }; + let viewport = h.state().views[0].viewport_rect(); + assert!(editors(&h).iter().any(|r| viewport.contains(r.center())), "the composer opens in the document"); + // The page scrolled up: it still shows, the comment near its top doesn't. + h.state_mut().views[0].goto = Some((0, 0.4)); + h.run_steps(4); + assert!(h.state().views[0].page_screen_rect(0).is_some_and(|r| r.bottom() > viewport.top()), "page 1 still shows"); + assert!(at(&h, 250.0, 180.0).y < viewport.top(), "the comment is scrolled away"); + let shown = editors(&h); + assert!(shown.is_empty(), "the composer stays with its comment, not over the toolbar: {shown:?} (document {viewport:?})"); + // Back again, the draft is still there. + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + assert!(editors(&h).iter().any(|r| viewport.contains(r.center())), "the composer is back"); + assert_eq!(h.state().views[0].comments.composer.as_ref().map(|c| c.text.as_str()), Some("Draft")); +} + /// The rectangle of the comment whose text starts with `text`. fn rect_of(h: &Harness<'static, PdfCraftApp>, text: &str) -> [f32; 4] { let c = comments(h); From cdc0834f349e66732d1d6b6b3a7e5779b4b2a53a Mon Sep 17 00:00:00 2001 From: mmmaarian03-crypto <253881360+mmmaarian03-crypto@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:42:54 +0300 Subject: [PATCH 102/254] Comments: the composer refocuses after its page was off screen (#4021) When the whole page left the screen, `composer` returned before it set `focus`, so the editor didn't take the keyboard again when the page came back (review on #4095). That path now asks for the focus too. Test `the_composer_takes_the_keyboard_back_after_its_page_was_off_screen` scrolls page 1 off the screen and back; it fails without the change. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/comments.rs | 5 ++++- crates/ui-egui/tests/it/comments.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index df2be3780..3cdbb953d 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1559,7 +1559,10 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, let c = view.comments.composer.as_ref()?; let page = c.page; let Some(xf) = view.page_xform(page) else { - // Scrolled away: keep the draft until the page is back. + // Scrolled away: keep the draft until the page is back, and give it the keyboard then. + if let Some(c) = view.comments.composer.as_mut() { + c.focus = true; + } return None; }; let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs, allowed: true, hidden: false, redact_mark: ([0.89, 0.13, 0.13], 1.0) }; diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 336f7d215..d97c09fbf 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -703,6 +703,28 @@ fn the_composer_hides_while_its_comment_is_scrolled_away() { assert_eq!(h.state().views[0].comments.composer.as_ref().map(|c| c.text.as_str()), Some("Draft")); } +/// With its whole page scrolled off the screen the composer keeps its draft, and the editor takes +/// the keyboard again when the page comes back (#4021). +#[test] +fn the_composer_takes_the_keyboard_back_after_its_page_was_off_screen() { + let mut h = harness(|app| app.set_option("quick", "note").unwrap()); + click_pt(&mut h, (250.0, 180.0)); + field(&h, "Add a comment").type_text("Draft"); + h.run_steps(2); + let editor_focused = + |h: &Harness<'static, PdfCraftApp>| h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput).any(|n| n.is_focused()); + assert!(editor_focused(&h), "the new note's editor has the keyboard"); + let viewport = h.state().views[0].viewport_rect(); + h.state_mut().views[0].goto = Some((1, 0.6)); + h.run_steps(4); + assert!(h.state().views[0].page_screen_rect(0).is_none_or(|r| r.bottom() < viewport.top()), "page 1 is off the screen"); + assert!(!editor_focused(&h), "nothing to type into while the page is away"); + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + assert_eq!(h.state().views[0].comments.composer.as_ref().map(|c| c.text.as_str()), Some("Draft")); + assert!(editor_focused(&h), "the editor has the keyboard again"); +} + /// The rectangle of the comment whose text starts with `text`. fn rect_of(h: &Harness<'static, PdfCraftApp>, text: &str) -> [f32; 4] { let c = comments(h); From e7dd758d332acee3b38788b735a223b80a34c5ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Lu=C3=ADs=20da=20Silva?= Date: Fri, 23 Oct 2026 03:14:49 -0300 Subject: [PATCH 103/254] fix(audit): covered_text reports the words under the cover, not the whole line A text line joins runs painted on one baseline, and the audit reported the line's whole text for a cover over part of it, including words painted beside the cover that stay visible (`SECRETtail` for a cover over `SECRET`). TextLine now keeps each joined run's text with the end of its word boxes (TextLine::runs), and the audit reports the runs whose words the cover overlaps. A single run, a line reordered for reading (right-to-left or visual-order Indic), or a cover that overlaps the line's box but none of its words still reports the whole line. Fixes #4164 --- crates/audit/src/lib.rs | 43 ++++++++++++++++++++++++++++++------ crates/audit/src/tests.rs | 38 ++++++++++++++++++++++++++++--- crates/edit/src/text.rs | 30 +++++++++++++++++++++++++ parity/acrobat-features.toml | 2 +- 4 files changed, 102 insertions(+), 11 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index f1f135098..c3518ca83 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -144,10 +144,17 @@ impl Budget { 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, or `None` when they show - /// no text. Without `copying` the text is empty and never assembled: one long line under many - /// covers would otherwise be copied once per cover, outside both bounds. - fn hidden_text(&mut self, lines: &[TextLine], copying: bool, hides: impl Fn(&TextLine) -> bool) -> Result, AuditError> { + /// The text `cover` hides in the `lines` that `hides` ([`covered_words`]), joined with single + /// spaces, or `None` when they show no text. Without `copying` the text is empty and never + /// assembled: one long line under many covers would otherwise be copied once per cover, + /// outside both bounds. + fn hidden_text( + &mut self, + lines: &[TextLine], + copying: bool, + cover: &[f64; 4], + hides: impl Fn(&TextLine) -> bool, + ) -> Result, AuditError> { self.comparisons = self.comparisons.saturating_add(self.page_cost); if self.comparisons > self.limits.comparisons { return Err(AuditError::TooManyComparisons); @@ -160,7 +167,7 @@ impl Budget { if !covered.is_empty() { covered.push(' '); } - covered.push_str(line.text.trim()); + covered_words(cover, line, &mut covered); } Ok((!covered.trim().is_empty()).then_some(covered)) } @@ -188,14 +195,17 @@ fn audit(doc: &Document, limits: Limits) -> Result, AuditError budget.start_page(&lines); // 1. Opaque filled rectangles over extractable text. for r in cover::filled_rects(doc, &page.dict)? { - if let Some(covered) = budget.hidden_text(&lines, copying, |line| is_cover(&r, line))? { + if let Some(covered) = budget.hidden_text(&lines, copying, &r.rect, |line| is_cover(&r, line))? { let covered_text = budget.report_text(covered, copying)?; out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text }); } } // 2. Annotation covers over extractable text. for annot in annots(doc, &page.dict)? { - if let Some(covered) = budget.hidden_text(&lines, copying, |line| match &annot { + let cover = match &annot { + AnnotationCover::Redact(rect) | AnnotationCover::Square { rect, .. } => *rect, + }; + if let Some(covered) = budget.hidden_text(&lines, copying, &cover, |line| match &annot { AnnotationCover::Redact(rect) => covers(rect, line), AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST, })? { @@ -250,6 +260,25 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool { inked || (boxed && (line.ink.is_empty() || word_intersects())) } +/// Appends the text of `line` that `cover` lies on to `out`: the runs whose words it overlaps +/// (#4164), so the words of the line painted beside the cover, which stay visible, are not +/// reported as hidden. The whole line when its runs can't say: a single run, a line whose text +/// was reordered for reading, or a cover that overlaps the line's box but none of its words. +fn covered_words(cover: &[f64; 4], line: &TextLine, out: &mut String) { + let overlaps = |w: &[f64; 4]| area(&[cover[0].max(w[0]), cover[1].max(w[1]), cover[2].min(w[2]), cover[3].min(w[3])]) > 0.0; + let mut runs = line.runs().filter(|(text, ink)| !text.trim().is_empty() && ink.iter().any(overlaps)).peekable(); + if line.runs().nth(1).is_none() || runs.peek().is_none() { + out.push_str(line.text.trim()); + return; + } + for (i, (text, _)) in runs.enumerate() { + if i > 0 { + out.push(' '); + } + out.push_str(text.trim()); + } +} + /// The area of a rectangle, 0 when it is empty or inverted. fn area(r: &[f64; 4]) -> f64 { (r[2] - r[0]).max(0.0) * (r[3] - r[1]).max(0.0) diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 3aca406e7..f3edb0c0b 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -645,6 +645,37 @@ fn later_text_on_the_line_does_not_clear_an_earlier_cover() { assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); } +/// #4164: a cover over part of a line reports the words under it, not the words of the same +/// line painted beside it, which stay visible. +#[test] +fn covered_text_leaves_out_the_words_beside_the_cover() { + let content = b"0 g +BT /F1 24 Tf 100 700 Td (SECRET) Tj ET +98 688 94 32 re f +BT /F1 24 Tf 200 700 Td (tail) Tj ET +"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.iter().map(|f| f.covered_text.as_str()).collect::>(), ["SECRET"]); + + // The same for an unapplied redaction mark over the first run only; a mark over both runs + // reports both. + for (rect, covered) in [("[98 688 192 720]", "SECRET"), ("[98 688 260 720]", "SECRET tail")] { + 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 (SECRET) Tj ET +BT /F1 24 Tf 200 700 Td (tail) Tj ET +", + ); + b.add(FONT); + b.add(&format!("<< /Type /Annot /Subtype /Redact /Rect {rect} >>")); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.iter().map(|f| (f.kind.clone(), f.covered_text.as_str())).collect::>(), [(FindingKind::UnappliedMark, covered)]); + } +} + /// 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] @@ -714,14 +745,15 @@ fn copy_restricted_documents_withhold_covered_text() { /// per cover, a volume neither the comparison nor the output bound charges. #[test] fn withheld_covered_text_is_not_assembled() { + const PAGE: [f64; 4] = [0.0, 0.0, 612.0, 792.0]; let doc = open(one_page(b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n", "")); let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text"); let mut budget = crate::Budget { limits: crate::Limits::DEFAULT, comparisons: 0, page_cost: 0, covered_text: 0 }; budget.start_page(&lines); - let withheld = budget.hidden_text(&lines, false, |_| true).expect("within the budget"); + let withheld = budget.hidden_text(&lines, false, &PAGE, |_| true).expect("within the budget"); assert_eq!(withheld.map(|t| t.capacity()), Some(0), "a finding, with no text built for it"); - assert_eq!(budget.hidden_text(&lines, false, |_| false).expect("within the budget"), None); - let copied = budget.hidden_text(&lines, true, |_| true).expect("within the budget"); + assert_eq!(budget.hidden_text(&lines, false, &PAGE, |_| false).expect("within the budget"), None); + let copied = budget.hidden_text(&lines, true, &PAGE, |_| true).expect("within the budget"); assert_eq!(copied.as_deref(), Some("SECRET DATA 123")); } diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 29ac90abc..fe4ff4202 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -80,6 +80,9 @@ pub struct TextLine { /// Why the line can't be edited: its font's writing mode (its CMap's `WMode`) couldn't be read, /// so which way its glyphs advance is unknown. The line is listed as horizontal text. unreadable_mode: Option, + /// The runs the line joins, in content order: each one's text and the end of its words in + /// `ink`. Empty when the line's text was put into another order (see [`TextLine::runs`]). + runs: Vec<(String, usize)>, } impl TextLine { @@ -539,6 +542,20 @@ impl TextLine { (self.stream, self.ops.iter().min().copied().unwrap_or(0)) } + /// The runs of text the line joins, in content order: each run's text as its glyphs show it + /// and the boxes of its words (a part of `ink`). Joining adds no space between runs, so a + /// run's text is not a word of `text` that can be found by position. None when the line's + /// text was reordered for reading (right-to-left or visual-order Indic text): then only the + /// line's text as a whole says what it reads. + pub fn runs(&self) -> impl Iterator { + let mut start = 0; + self.runs.iter().map(move |(text, end)| { + let ink = self.ink.get(start..*end).unwrap_or_default(); + start = *end; + (text.as_str(), ink) + }) + } + fn rotation(&self) -> f64 { let m = Matrix(self.origin.tm).then(&Matrix(self.origin.ctm)).0; m[1].atan2(m[0]).to_degrees() @@ -1367,6 +1384,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; l.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); + l.runs.push((s.text.clone(), l.ink.len())); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; @@ -1399,6 +1417,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> actual_text: s.actual_text, clips: s.state.render_mode >= 4, unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned), + runs: vec![(s.text.clone(), s.ink.len())], ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -1418,10 +1437,15 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> // Content streams draw right-to-left lines in visual order; the editor works in typing order. for l in lines.iter_mut().filter(|l| !l.logical_order) { // A visual-order Indic font draws pre-base vowel signs before their consonant. + let shown = l.text.clone(); if l.visual_order && !l.actual_text { l.text = crate::indic::to_logical(&l.text); } l.text = crate::bidi::to_logical(&l.text); + // The runs hold the text in drawing order, which no longer reads as the line does. + if l.text != shown { + l.runs.clear(); + } } // Lines of only spaces aren't editable text. lines.retain(|l| !l.text.trim().is_empty()); @@ -1470,6 +1494,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; l.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); + l.runs.push((s.text.clone(), l.ink.len())); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; @@ -1501,6 +1526,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream actual_text: s.actual_text, clips: s.state.render_mode >= 4, unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned), + runs: vec![(s.text.clone(), s.ink.len())], ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -1681,7 +1707,11 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E let mut lines = w.lines; // A visual-order Indic font draws pre-base vowel signs before their consonant. for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) { + let shown = l.text.clone(); l.text = crate::indic::to_logical(&l.text); + if l.text != shown { + l.runs.clear(); + } } lines.retain(|l| !l.text.trim().is_empty()); Ok((lines, w.budget.truncated)) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6a2d0ccf9..53bfe6a14 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-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." +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 finding's covered text is the runs of the line whose words the cover overlaps, not the words of the line painted beside it (#4164); a line of one run, a right-to-left or visual-order line, or a cover over none of its words reports the whole line. 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 0e883c44a00815a40e00dcf2532c63ceee952e21 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:30:44 +0800 Subject: [PATCH 104/254] M6: values typed into Password fields are written to no file (#3926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A text field with the Password flag kept its typed value in /V, so Save, a full rewrite, Reduce File Size and the recovery snapshots all wrote it in plain text. ISO 32000-2 §12.7.5.3, Table 231 (note to the Password flag): PDF processors should never store the value in the PDF file. Each of those paths now writes a copy without the values; the open document keeps them in memory, and the masked appearance is kept. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 34 ++++++++++++++++++++++--- crates/engine/src/optimizer.rs | 2 ++ crates/engine/src/tests.rs | 46 ++++++++++++++++++++++++++++++++++ crates/forms/src/lib.rs | 24 ++++++++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f257865d3..56a1a2409 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2905,6 +2905,19 @@ fn renderer_password(bytes: &[u8]) -> String { } } +/// The document as it may be written to a file (#3926): a copy without the values of text fields +/// with the Password flag, or `cos` itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231 +/// (a note to the Password flag): "it is imperative that PDF processors never store the value of +/// the text field in the PDF file if this flag is set". The open document keeps them in memory. +fn writable(cos: &pdfcraft_cos::Document) -> Result, EditError> { + if !pdfcraft_forms::has_password_values(cos) { + return Ok(std::borrow::Cow::Borrowed(cos)); + } + let mut copy = cos.clone(); + pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?; + Ok(std::borrow::Cow::Owned(copy)) +} + /// The last-resort guard (AGENTS.md §4): run `f`, turning a panic that escapes it into an error /// message, so one bad file or edit can't take the app and its other documents down. It is a /// safety net for bugs, not a substitute for returning errors. @@ -4270,12 +4283,13 @@ impl Session { // file so that revision stays in the chain. A full rewrite (redaction, new encryption, // a reconstructed file) still writes the current graph on its own. let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); + let cos = writable(&editor.cos)?; guard(|| { if on_saved || rewrite { - write_incremental(&editor.cos, &opts) + write_incremental(&cos, &opts) } else { let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; - base.overlay_changes_from(&editor.cos)?; + base.overlay_changes_from(&cos)?; write_incremental(&base, &opts) } }) @@ -4292,7 +4306,8 @@ impl Session { } let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - guard(|| write_full(&editor.cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + let cos = writable(&editor.cos)?; + guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) } /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this @@ -4916,12 +4931,23 @@ impl Session { let mut out = Vec::new(); for d in &mut self.docs { if d.dirty && d.generation != d.snapshot_generation { + // The working bytes hold typed Password-field values (#3926): a recovery file is + // written from a copy without them, and none is written if that copy fails. + let bytes = match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos)) { + Some(e) => match writable(&e.cos) + .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) + { + Ok(b) => Arc::new(b), + Err(_) => continue, + }, + None => d.bytes.clone(), + }; d.snapshot_generation = d.generation; out.push(RecoverySnapshot { doc: d.id, name: d.name.clone(), path: d.path.clone(), - bytes: d.bytes.clone(), + bytes, encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), }); } diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index ee8c957f9..444c428ca 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -83,6 +83,8 @@ impl OptimizeJob { fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result { let OptimizeJob { mut cos, settings, discard, opts } = self; + // Like every file written, without the values of Password-flag fields (#3926). + pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) }; step(OptimizeStage::Discarding)?; let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index afe6fd06c..c676510e8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2025,6 +2025,52 @@ fn form_fixture() -> Vec { out } +/// Whether `needle` appears in `bytes`, or in any stream of the PDF they hold once decoded (so +/// compressed object streams are searched too), as literal or hex string bytes. +fn appears_in_pdf(bytes: &Arc>, needle: &str) -> bool { + let hex: String = needle.bytes().map(|b| format!("{b:02x}")).collect(); + let forms = [needle.as_bytes().to_vec(), hex.clone().into_bytes(), hex.to_uppercase().into_bytes()]; + let found = |hay: &[u8]| forms.iter().any(|f| hay.windows(f.len()).any(|w| w == f.as_slice())); + if found(bytes) { + return true; + } + let doc = pdfcraft_cos::Document::open(bytes.clone()).unwrap(); + doc.object_numbers().into_iter().any(|num| match &*doc.get(pdfcraft_cos::ObjRef { num, generation: 0 }) { + pdfcraft_cos::Object::Stream(st) => st.decoded().is_ok_and(|d| found(&d)), + _ => false, + }) +} + +/// #3926: a value typed into a text field with the Password flag stays in the open document but +/// is written to no file: Save, a full rewrite, Reduce File Size or a recovery snapshot. ISO +/// 32000-2 §12.7.5.3, Table 231, note to the Password flag: PDF processors should never store +/// the value in the PDF file. +#[test] +fn password_field_values_are_kept_in_memory_but_written_to_no_file() { + let fixture = String::from_utf8(form_fixture()).unwrap().replace("/T (name)", "/T (name) /Ff 8192"); + // Offsets shift by the inserted text; the parser rebuilds the cross-reference table. + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, Arc::new(fixture.into_bytes()), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("hunter2".into()) }).unwrap(); + let field = s.get(id).unwrap().form.iter().find(|f| f.name == "name").cloned().unwrap(); + assert!(field.has(pdfcraft_forms::flags::PASSWORD)); + assert_eq!(field.value, vec!["hunter2".to_string()], "the open document keeps the value"); + assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes do hold it (the test can see it)"); + + let saved = s.save_bytes(id).unwrap(); + assert!(!appears_in_pdf(&saved, "hunter2"), "Save"); + assert!(appears_in_pdf(&saved, "*******"), "the masked appearance is kept"); + assert!(!appears_in_pdf(&s.save_full_bytes(id).unwrap(), "hunter2"), "a full rewrite"); + assert!(!appears_in_pdf(&s.reduced_bytes(id).unwrap().0, "hunter2"), "Reduce File Size"); + let snaps = s.autosave_snapshots(); + assert_eq!(snaps.len(), 1); + assert!(!appears_in_pdf(&snaps[0].bytes, "hunter2"), "the recovery snapshot"); + // Reopened, the field is empty, as in any reader. + let mut t = Session::new(); + let reopened = t.open("saved.pdf", None, saved, None).unwrap(); + assert!(t.get(reopened).unwrap().form.iter().find(|f| f.name == "name").unwrap().value.iter().all(|v| v.is_empty())); +} + /// A value its field's font can't draw is kept, and the edit that drew it reports it in /// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a /// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 82c1c8e74..fe04f0fba 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1293,6 +1293,30 @@ pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> Ok(()) } +/// Whether any text field with the Password flag holds a value (`/V`) in `doc`. +pub fn has_password_values(doc: &Document) -> bool { + fields(doc).iter().any(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && doc.get(f.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())) +} + +/// Remove the value (`/V`) of every text field with the Password flag, for a copy of the +/// document about to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password +/// flag, note: "it is imperative that PDF processors never store the value of the text field in +/// the PDF file if this flag is set". The appearance keeps its masking characters. Returns how +/// many values were removed. +pub fn clear_password_values(doc: &mut Document) -> Result { + let holders: Vec = fields(doc) + .into_iter() + .filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && doc.get(f.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())) + .map(|f| f.obj) + .collect(); + for r in &holders { + doc.update_dict(*r, |d| { + d.remove(b"V"); + })?; + } + Ok(holders.len()) +} + /// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). pub fn need_appearances(doc: &Document) -> bool { acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))) From 509b5d5cf350682d1b4476dc79d9c75d94928db3 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:54:56 +0800 Subject: [PATCH 105/254] M6: clear Password values inherited from an ancestor or held by a widget (#3926) Review follow-up: the helpers checked only the field dictionary's own /V. A value inherited through /Parent or carried by a separate widget was missed, so the document could be written as it was. Detection now uses the resolved value and the widgets; clearing removes /V from the field, its widgets and every ancestor only password fields inherit from, and gives the field an empty value of its own where a shared ancestor value has to stay. Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/lib.rs | 88 ++++++++++++++++++++++++++++++++------- crates/forms/src/tests.rs | 34 +++++++++++++++ 2 files changed, 106 insertions(+), 16 deletions(-) diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index fe04f0fba..e5ce6a00e 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1293,28 +1293,84 @@ pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> Ok(()) } -/// Whether any text field with the Password flag holds a value (`/V`) in `doc`. -pub fn has_password_values(doc: &Document) -> bool { - fields(doc).iter().any(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && doc.get(f.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())) +/// Text fields with the Password flag (inherited or their own) whose value (`/V`, inherited or +/// their own) is not empty, or one of whose separate widgets carries a `/V`. +fn password_fields(doc: &Document) -> Vec { + let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())); + fields(doc) + .into_iter() + .filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (f.value.iter().any(|v| !v.is_empty()) || on_widget(f))) + .collect() } -/// Remove the value (`/V`) of every text field with the Password flag, for a copy of the -/// document about to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password -/// flag, note: "it is imperative that PDF processors never store the value of the text field in -/// the PDF file if this flag is set". The appearance keeps its masking characters. Returns how -/// many values were removed. +/// Whether any text field with the Password flag holds a value in `doc`. +pub fn has_password_values(doc: &Document) -> bool { + !password_fields(doc).is_empty() +} + +/// `r` and its field ancestors (`/Parent`), nearest first, at most 32 deep and without repeats. +fn field_chain(doc: &Document, r: ObjRef) -> Vec { + let mut chain = vec![r]; + let mut cur = r; + while chain.len() < 32 { + let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break }; + if chain.contains(&parent) { + break; + } + chain.push(parent); + cur = parent; + } + chain +} + +/// Remove the values of the text fields with the Password flag, for a copy of the document about +/// to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password flag, note: "it is +/// imperative that PDF processors never store the value of the text field in the PDF file if +/// this flag is set". The appearance keeps its masking characters. +/// +/// A value can sit on the field, on one of its widgets, or on an ancestor it inherits from +/// (`/V` is inheritable). Each is removed; an ancestor's only when every field inheriting from it +/// is such a password field. Where an ancestor's value is shared with other fields and stays, the +/// password field gets an empty value of its own so it no longer inherits it. Returns how many +/// fields were cleared. pub fn clear_password_values(doc: &mut Document) -> Result { - let holders: Vec = fields(doc) - .into_iter() - .filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && doc.get(f.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())) - .map(|f| f.obj) - .collect(); - for r in &holders { - doc.update_dict(*r, |d| { + let targets = password_fields(doc); + if targets.is_empty() { + return Ok(0); + } + let all = fields(doc); + let is_password = |f: &Field| f.kind == FieldKind::Text && f.has(flags::PASSWORD); + for f in &targets { + let chain = field_chain(doc, f.obj); + for w in &f.widgets { + if w.obj != f.obj { + doc.update_dict(w.obj, |d| { + d.remove(b"V"); + })?; + } + } + doc.update_dict(f.obj, |d| { d.remove(b"V"); })?; + let mut shared_value_kept = false; + for a in chain.iter().skip(1) { + if doc.get(*a).as_dict().is_none_or(|d| d.get(b"V").is_none()) { + continue; + } + let exclusive = all.iter().filter(|g| field_chain(doc, g.obj).contains(a)).all(is_password); + if exclusive { + doc.update_dict(*a, |d| { + d.remove(b"V"); + })?; + } else { + shared_value_kept = true; + } + } + if shared_value_kept { + doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?; + } } - Ok(holders.len()) + Ok(targets.len()) } /// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 05b14d933..d72c0da0e 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3073,3 +3073,37 @@ fn a_skipped_field_leaves_its_widget_to_the_next() { let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect(); assert_eq!(widgets, [("b", 1)]); } + +/// #3926 / #3943 review: a password value can be inherited from an ancestor field or sit on a +/// separate widget. Clearing removes both; an ancestor's value shared with a non-password field +/// stays for that field, and the password field gets an empty value of its own instead. +#[test] +fn inherited_and_widget_password_values_are_cleared() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 9 0 R 11 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 8 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4 + "<< /T (acct) /FT /Tx /Ff 8192 /V (secret1) /Kids [6 0 R] >>".into(), // 5 flag and value inherited + "<< /Type /Annot /Subtype /Widget /T (pin) /Parent 5 0 R /Rect [50 700 150 720] /P 3 0 R >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /Parent 8 0 R /V (secret3) /Rect [50 650 150 670] /P 3 0 R >>".into(), // 7 value on the widget + "<< /FT /Tx /T (code) /Ff 8192 /Kids [7 0 R] >>".into(), // 8 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw) /Ff 8192 /Parent 10 0 R /Rect [50 600 150 620] /P 3 0 R >>".into(), // 9 + "<< /T (grp) /V (shared2) /Kids [9 0 R 11 0 R] >>".into(), // 10 shared with a plain field + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (plain) /Parent 10 0 R /Rect [50 550 150 570] /P 3 0 R >>".into(), // 11 + ]; + let mut doc = document(&objs); + assert!(has_password_values(&doc)); + let value = |doc: &Document, n: &str| fields(doc).into_iter().find(|f| f.name == n).map(|f| f.value).unwrap_or_default(); + assert_eq!(value(&doc, "acct.pin"), ["secret1"], "inherited before"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 3); + assert!(!has_password_values(&doc)); + for n in ["acct.pin", "code", "grp.pw"] { + assert!(value(&doc, n).iter().all(|v| v.is_empty()), "{n} cleared"); + } + assert_eq!(value(&doc, "grp.plain"), ["shared2"], "the shared value stays for the plain field"); + let has_v = |doc: &Document, n: u32| doc.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }).as_dict().is_some_and(|d| d.get(b"V").is_some()); + assert!(!has_v(&doc, 5), "an ancestor only password fields inherit from loses its value"); + assert!(!has_v(&doc, 7), "a widget's value is removed"); + assert!(has_v(&doc, 10), "a shared ancestor keeps its value"); +} From 048cf14d313ad52740546df28ef68a910e72ad71 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:24:08 +0800 Subject: [PATCH 106/254] M6: Extract, Split and Combine write no Password-field values either (#3926) CodeRabbit on #3943: Extract Pages copies the fields of the pages it takes, and write_new (Combine, Extract, Split, Create) wrote that copy without the writable() boundary saves use, so the new file kept a typed Password value. write_unchecked, which write_new and Split's size measuring both go through, now writes the sanitized copy. The other write_full/write_incremental calls in the engine build in-memory bytes (display, previews, the working copy, print rasters), not files. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 5 ++++- crates/engine/src/tests.rs | 12 +++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 56a1a2409..63dcce44b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4915,9 +4915,12 @@ impl Session { } /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. + /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and + /// Split copy the fields of the pages they take. fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + let doc = writable(doc)?; + write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) } /// Whether a changed document still needs a recovery snapshot. This does not consume it. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c676510e8..df8039fc2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2042,7 +2042,8 @@ fn appears_in_pdf(bytes: &Arc>, needle: &str) -> bool { } /// #3926: a value typed into a text field with the Password flag stays in the open document but -/// is written to no file: Save, a full rewrite, Reduce File Size or a recovery snapshot. ISO +/// is written to no file: Save, a full rewrite, Reduce File Size, a recovery snapshot, or a new +/// file made from its pages (Extract, Split, Combine). ISO /// 32000-2 §12.7.5.3, Table 231, note to the Password flag: PDF processors should never store /// the value in the PDF file. #[test] @@ -2065,6 +2066,15 @@ fn password_field_values_are_kept_in_memory_but_written_to_no_file() { let snaps = s.autosave_snapshots(); assert_eq!(snaps.len(), 1); assert!(!appears_in_pdf(&snaps[0].bytes, "hunter2"), "the recovery snapshot"); + // New files built from its pages too (#3943 review): Extract copies the fields of the pages it + // takes, and Split writes each part the same way. + let extracted = s.extract(id, &[0]).unwrap(); + assert!(!appears_in_pdf(&extracted, "hunter2"), "Extract Pages"); + assert!(appears_in_pdf(&extracted, "*******"), "the extracted page keeps the masked appearance"); + for (_, _, part) in s.split_by_size(id, 1).unwrap() { + assert!(!appears_in_pdf(&part, "hunter2"), "Split"); + } + assert!(!appears_in_pdf(&s.combine(&[("form.pdf".into(), s.get(id).unwrap().bytes.clone())]).unwrap(), "hunter2"), "Combine"); // Reopened, the field is empty, as in any reader. let mut t = Session::new(); let reopened = t.open("saved.pdf", None, saved, None).unwrap(); From 64c5fd8ef0e7f612849876df748eab8463345348 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:00:54 +0800 Subject: [PATCH 107/254] M6: signing, timestamping and DSS writes carry no Password-field values (#3926) CodeRabbit on #3943: sign, sign_with_timestamp, timestamp_document and dss::embed each write a new incremental file from editor.cos, outside the writable() boundary saves use, so a typed Password value was signed into the file. Each now writes from the writable() copy. The new test signs a form with a typed value and checks the signed file holds the masked appearance but not the value; the three TSA/DSS paths need a timestamp server or validation data, and use the same one-line change. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 13 +++++++++---- crates/engine/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 63dcce44b..dd5c4aafa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -5104,7 +5104,9 @@ impl Session { if opts.date.is_empty() { opts.date = self.signing_date(); } - Ok(Arc::new(pdfcraft_sign::sign(&editor.cos, id, &opts)?)) + // A typed Password-field value is not signed into the file (#3926), as it isn't saved. + let cos = writable(&editor.cos)?; + Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) } /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by @@ -5121,7 +5123,8 @@ impl Session { if opts.date.is_empty() { opts.date = self.signing_date(); } - let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&editor.cos, id, &opts, tsa)?); + let cos = writable(&editor.cos)?; + let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); // Checked like a save before anyone writes it (#2166). self.check_save(doc, &signed)?; Ok(signed) @@ -5133,7 +5136,8 @@ impl Session { let d = self.get(doc).ok_or(EditError::NoDocument)?; let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; let date = if date.is_empty() { self.signing_date() } else { date }; - let stamped = Arc::new(pdfcraft_sign::timestamp_document(&editor.cos, tsa, &date)?); + let cos = writable(&editor.cos)?; + let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); self.check_save(doc, &stamped)?; Ok(stamped) } @@ -5143,7 +5147,8 @@ impl Session { pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { let d = self.get(doc).ok_or(EditError::NoDocument)?; let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let embedded = Arc::new(pdfcraft_sign::dss::embed(&editor.cos, evidence)?); + let cos = writable(&editor.cos)?; + let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); self.check_save(doc, &embedded)?; Ok(embedded) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index df8039fc2..e8016fb8c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2081,6 +2081,23 @@ fn password_field_values_are_kept_in_memory_but_written_to_no_file() { assert!(t.get(reopened).unwrap().form.iter().find(|f| f.name == "name").unwrap().value.iter().all(|v| v.is_empty())); } +/// #3926 / #3943 review: signing writes the signed file from the same copy a save does, so a +/// typed Password-field value is not signed into the file either. +#[test] +fn a_signed_file_carries_no_password_field_value() { + 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 fixture = String::from_utf8(form_fixture()).unwrap().replace("/T (name)", "/T (name) /Ff 8192"); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("form.pdf", None, Arc::new(fixture.into_bytes()), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("hunter2".into()) }).unwrap(); + assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes hold it"); + let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap(); + assert!(!appears_in_pdf(&signed, "hunter2"), "the signed file"); + assert!(appears_in_pdf(&signed, "*******"), "the masked appearance is signed in"); +} + /// A value its field's font can't draw is kept, and the edit that drew it reports it in /// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a /// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other From adc8307c0bc885f1443ebb35b279fb352df07882 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:18:11 +0800 Subject: [PATCH 108/254] M6: a saved Password field returns to its default value and is redrawn from it (#3926) Review on #3943. A black-box test of Acrobat (synthetic form, Save As and Ctrl+S, every decoded object searched) showed how it saves a Password field: the typed value is nowhere in the file; a field with /DV is saved holding /DV, one without is left empty; the asterisks are drawn from the saved value (5 for a 5-letter default, not 13 for the typed password); /DV and /AA are kept. clear_password_values now does the same: it restores the (inherited) /DV as the value, or leaves the field empty, and redraws the widgets, so the appearance no longer shows how long the typed password was. A field already at its default no longer counts as holding a password, so such a form needs no cleaned copy. The parity note for form.fill-text says so and lists the two limits Acrobat shares (a script copying the password into an ordinary field, and an earlier revision kept by an incremental save). Mutants, each failing a test: no /DV restore, no redraw, the default ignored in password_fields, emptiness ignored (an empty string in an array). Dropping the empty-default filter was equivalent, so the filter is gone. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 8 +++++--- crates/forms/src/lib.rs | 22 ++++++++++++++-------- crates/forms/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e8016fb8c..1cd6f5f1c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2060,7 +2060,9 @@ fn password_field_values_are_kept_in_memory_but_written_to_no_file() { let saved = s.save_bytes(id).unwrap(); assert!(!appears_in_pdf(&saved, "hunter2"), "Save"); - assert!(appears_in_pdf(&saved, "*******"), "the masked appearance is kept"); + // As Acrobat saves it (#3943 review): no default value, so an empty field whose appearance + // no longer shows how long the typed password was. + assert!(!appears_in_pdf(&saved, "*******"), "the typed length is not kept in the appearance"); assert!(!appears_in_pdf(&s.save_full_bytes(id).unwrap(), "hunter2"), "a full rewrite"); assert!(!appears_in_pdf(&s.reduced_bytes(id).unwrap().0, "hunter2"), "Reduce File Size"); let snaps = s.autosave_snapshots(); @@ -2070,7 +2072,7 @@ fn password_field_values_are_kept_in_memory_but_written_to_no_file() { // takes, and Split writes each part the same way. let extracted = s.extract(id, &[0]).unwrap(); assert!(!appears_in_pdf(&extracted, "hunter2"), "Extract Pages"); - assert!(appears_in_pdf(&extracted, "*******"), "the extracted page keeps the masked appearance"); + assert!(!appears_in_pdf(&extracted, "*******"), "nor on the extracted page"); for (_, _, part) in s.split_by_size(id, 1).unwrap() { assert!(!appears_in_pdf(&part, "hunter2"), "Split"); } @@ -2095,7 +2097,7 @@ fn a_signed_file_carries_no_password_field_value() { assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes hold it"); let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap(); assert!(!appears_in_pdf(&signed, "hunter2"), "the signed file"); - assert!(appears_in_pdf(&signed, "*******"), "the masked appearance is signed in"); + assert!(!appears_in_pdf(&signed, "*******"), "nor the typed length"); } /// A value its field's font can't draw is kept, and the edit that drew it reports it in diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index e5ce6a00e..08c9686a0 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1294,13 +1294,12 @@ pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> } /// Text fields with the Password flag (inherited or their own) whose value (`/V`, inherited or -/// their own) is not empty, or one of whose separate widgets carries a `/V`. +/// their own) is not empty and not their default value (`/DV`, which the form's author wrote and +/// a save restores), or one of whose separate widgets carries a `/V`. fn password_fields(doc: &Document) -> Vec { let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())); - fields(doc) - .into_iter() - .filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (f.value.iter().any(|v| !v.is_empty()) || on_widget(f))) - .collect() + let typed = |f: &Field| f.value.iter().any(|v| !v.is_empty()) && f.value.first() != f.default.first(); + fields(doc).into_iter().filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (typed(f) || on_widget(f))).collect() } /// Whether any text field with the Password flag holds a value in `doc`. @@ -1326,7 +1325,10 @@ fn field_chain(doc: &Document, r: ObjRef) -> Vec { /// Remove the values of the text fields with the Password flag, for a copy of the document about /// to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password flag, note: "it is /// imperative that PDF processors never store the value of the text field in the PDF file if -/// this flag is set". The appearance keeps its masking characters. +/// this flag is set". As Acrobat saves such a field (#3943 review, black-box test): a field with +/// a default value (`/DV`, which the form's author wrote) gets it back as its value, one without +/// is left empty, and its appearance is drawn again from that value, so the masking characters +/// no longer show how long the typed password was. `/DV` and the field's scripts stay. /// /// A value can sit on the field, on one of its widgets, or on an ancestor it inherits from /// (`/V` is inheritable). Each is removed; an ancestor's only when every field inheriting from it @@ -1366,9 +1368,13 @@ pub fn clear_password_values(doc: &mut Document) -> Result { shared_value_kept = true; } } - if shared_value_kept { - doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?; + let restored: Vec = f.default.first().cloned().into_iter().collect(); + match restored.first() { + Some(dv) => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(dv)))?, + None if shared_value_kept => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?, + None => {} } + redraw(doc, f, &restored, &mut NoScripts)?; } Ok(targets.len()) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index d72c0da0e..29593ddbd 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3107,3 +3107,35 @@ fn inherited_and_widget_password_values_are_cleared() { assert!(!has_v(&doc, 7), "a widget's value is removed"); assert!(has_v(&doc, 10), "a shared ancestor keeps its value"); } + +/// #3943 review: a cleared password field saves as Acrobat saves it (black-box test). One with a +/// default value gets it back, and its appearance shows as many masking characters as the +/// default has, not as the typed password had; one without is left empty and draws none. The +/// default value and the scripts stay, and a field already at its default needs no clearing. +#[test] +fn a_cleared_password_field_returns_to_its_default_value() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>".into(), // 3 + "<< /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), // 4 + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 5 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw) /Ff 8192 /DV (DEF-1) /V (NEWPASS-77777) /AA << /K << /S /JavaScript /JS (1;) >> >> /Rect [50 700 250 720] /P 3 0 R >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw3) /Ff 8192 /V (PW3-TYPED-9) /Rect [50 650 250 670] /P 3 0 R >>".into(), // 7 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (blank) /Ff 8192 /V [()] /Rect [50 600 250 620] /P 3 0 R >>".into(), // 8 holds nothing (an empty string in an array) + ]; + let mut doc = document(&objs); + assert_eq!(clear_password_values(&mut doc).unwrap(), 2); + let all = fields(&doc); + let (pw, pw3) = (field(&all, "pw"), field(&all, "pw3")); + assert_eq!(pw.value, ["DEF-1"], "back to its default"); + assert_eq!(pw.default, ["DEF-1"], "the default stays"); + assert!(pw.actions.scripts.keystroke.is_some(), "the scripts stay"); + assert!(pw3.value.iter().all(|v| v.is_empty()), "no default: empty"); + let shown = ap(&doc, &pw.widgets[0]); + assert!(shown.contains("(*****)") && !shown.contains("NEWPASS") && !shown.contains("******"), "{shown}"); + let shown3 = ap(&doc, &pw3.widgets[0]); + assert!(!shown3.contains('*') && !shown3.contains("PW3"), "{shown3}"); + assert!(!has_password_values(&doc), "a field at its default holds no typed password"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 0); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6a2d0ccf9..8c28a7663 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4440,8 +4440,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit"] -notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced." +evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit", "crates/engine/src/tests.rs::password_field_values_are_kept_in_memory_but_written_to_no_file", "crates/forms/src/tests.rs::a_cleared_password_field_returns_to_its_default_value"] +notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced. A text field with the Password flag is saved as Acrobat saves it (#3926, black-box test): the typed value is written to no file (Save, Save As, Reduce File Size, recovery, Extract/Split/Combine, signing); the field gets back its default value (/DV) or is left empty, and its asterisks are redrawn from that value. /DV and the field's scripts stay. Known limits, which Acrobat shares: a script that copies the password into an ordinary field saves it there (Acrobat recalculates a Calculate copy on save, PdfCraft does not), and an incremental save leaves a password stored by an earlier revision in that revision." [[feature]] id = "form.fill-checkbox-radio" From 9ebc237ecfa8e6dea02c6a1bc2bf684b71df5610 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:23:06 +0800 Subject: [PATCH 109/254] M6: skip forms without a Password field quickly, and find ancestors once (#3926) Review on #3943, point 1. has_password_values ran a full fields() walk on every save and recovery snapshot, and clear_password_values walked every field's ancestors again for each ancestor of each target. has_password_flag now answers first, reading only /Ff, /Kids and /Parent: the field tree from /Fields (seen set, 32 levels) and, as fields() does, the widgets on the pages with the fields above them. A form without such a field returns before any Field is built. clear_password_values finds each field's ancestors once. The test puts a page-only widget, a flag inherited from an unlisted parent and a nested field whose widget is on no page each alone in a document. Mutants that drop the page scan, the parent chain, the tree walk or the descent into kids each fail it, as does treating every ancestor as exclusive. Dropping the seen set or the early-out changes only speed (equivalent). Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/lib.rs | 39 +++++++++++++++++++++++++++++++++++-- crates/forms/src/tests.rs | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 08c9686a0..d892f9296 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1297,11 +1297,44 @@ pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> /// their own) is not empty and not their default value (`/DV`, which the form's author wrote and /// a save restores), or one of whose separate widgets carries a `/V`. fn password_fields(doc: &Document) -> Vec { + if !has_password_flag(doc) { + return Vec::new(); + } let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())); let typed = |f: &Field| f.value.iter().any(|v| !v.is_empty()) && f.value.first() != f.default.first(); fields(doc).into_iter().filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (typed(f) || on_widget(f))).collect() } +/// Whether any field dictionary sets the Password flag (#3943 review). A field that inherits the +/// flag has an ancestor that sets it, so that ancestor is found instead; a kid clearing the flag +/// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save +/// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like +/// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be +/// missing from `/Fields`. Each dictionary is visited once and at most 32 levels deep. +fn has_password_flag(doc: &Document) -> bool { + let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0); + let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default(); + let mut stack: Vec<(Object, usize)> = top.into_iter().map(|o| (o, 0)).collect(); + let mut seen = std::collections::HashSet::new(); + while let Some((o, depth)) = stack.pop() { + let Object::Ref(r) = o else { continue }; + if depth > 32 || !seen.insert(r) { + continue; + } + if sets_it(r) { + return true; + } + let kids = doc.get(r).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned())).unwrap_or_default(); + stack.extend(kids.into_iter().map(|k| (k, depth + 1))); + } + // Widgets on the pages, and the fields above them. + page_refs(doc).into_iter().any(|p| { + let annots = + doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())).unwrap_or_default(); + annots.iter().filter_map(Object::as_ref).filter(|r| !seen.contains(r)).any(|r| field_chain(doc, r).into_iter().any(sets_it)) + }) +} + /// Whether any text field with the Password flag holds a value in `doc`. pub fn has_password_values(doc: &Document) -> bool { !password_fields(doc).is_empty() @@ -1340,8 +1373,10 @@ pub fn clear_password_values(doc: &mut Document) -> Result { if targets.is_empty() { return Ok(0); } - let all = fields(doc); let is_password = |f: &Field| f.kind == FieldKind::Text && f.has(flags::PASSWORD); + // Each field's ancestors, found once: only values change below, never `/Parent` (#3943 + // review: walking them again for every ancestor of every target was quadratic). + let chains: Vec<(bool, Vec)> = fields(doc).iter().map(|g| (is_password(g), field_chain(doc, g.obj))).collect(); for f in &targets { let chain = field_chain(doc, f.obj); for w in &f.widgets { @@ -1359,7 +1394,7 @@ pub fn clear_password_values(doc: &mut Document) -> Result { if doc.get(*a).as_dict().is_none_or(|d| d.get(b"V").is_none()) { continue; } - let exclusive = all.iter().filter(|g| field_chain(doc, g.obj).contains(a)).all(is_password); + let exclusive = chains.iter().filter(|(_, chain)| chain.contains(a)).all(|(password, _)| *password); if exclusive { doc.update_dict(*a, |d| { d.remove(b"V"); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 29593ddbd..e152b0868 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3139,3 +3139,44 @@ fn a_cleared_password_field_returns_to_its_default_value() { assert!(!has_password_values(&doc), "a field at its default holds no typed password"); assert_eq!(clear_password_values(&mut doc).unwrap(), 0); } + +/// #3943 review: the quick check that skips forms without a Password field still sees one whose +/// widget is only on the page, not listed in `/Fields`; one that inherits the flag from a parent +/// that is not listed either; and one deeper in the field tree whose widget is on no page. Each +/// is alone in its own document, so each path of the check is needed. +#[test] +fn a_password_field_missing_from_the_field_list_is_still_cleared() { + let form = |fields: &str, annots: &str, rest: &[&str]| { + let mut objs: Vec = vec![ + "<< /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(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + format!("<< /Fields [{fields}] /DA (/Helv 0 Tf 0 g) >>"), + ]; + objs.extend(rest.iter().map(|o| o.to_string())); + document(&objs) + }; + let page_only = + form("", "5 0 R", &["<< /Type /Annot /Subtype /Widget /FT /Tx /T (pin) /Ff 8192 /V (secret5) /Rect [50 700 150 720] /P 3 0 R >>"]); + let inherited = form( + "", + "5 0 R", + &[ + "<< /Type /Annot /Subtype /Widget /T (kid) /Parent 6 0 R /Rect [50 650 150 670] /P 3 0 R >>", + "<< /FT /Tx /T (acct) /Ff 8192 /V (secret6) /Kids [5 0 R] >>", + ], + ); + let off_page = form( + "5 0 R", + "", + &[ + "<< /T (grp) /Kids [6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Ff 8192 /V (secret7) /Parent 5 0 R /Rect [0 0 0 0] >>", + ], + ); + for (what, mut doc) in [("page only", page_only), ("inherited", inherited), ("on no page", off_page)] { + assert!(has_password_values(&doc), "{what}"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1, "{what}"); + assert!(fields(&doc).iter().all(|f| f.value.iter().all(|v| v.is_empty())), "{what}: {:?}", fields(&doc)); + } +} From cd2b26d492b1b6102c88ea3b34defc2dd7c8e4da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Lu=C3=ADs=20da=20Silva?= Date: Fri, 23 Oct 2026 03:53:31 -0300 Subject: [PATCH 110/254] fix(audit): join covered runs the way the line joins them TextLine::runs also says whether joining put a space before each run, and covered_words uses it: a word shown in two runs with nothing between them (`SE` `CRET`) is reported as one word, not `SE CRET`. Runs with uncovered runs left out between them are still spaced. The regression test writes its content streams on one line each. Review on #4198 (CodeRabbit). --- crates/audit/src/lib.rs | 31 ++++++++++++++++++++----------- crates/audit/src/tests.rs | 28 ++++++++++++---------------- crates/edit/src/text.rs | 37 ++++++++++++++++++++----------------- 3 files changed, 52 insertions(+), 44 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index c3518ca83..46823a1fa 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -262,20 +262,29 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool { /// Appends the text of `line` that `cover` lies on to `out`: the runs whose words it overlaps /// (#4164), so the words of the line painted beside the cover, which stay visible, are not -/// reported as hidden. The whole line when its runs can't say: a single run, a line whose text -/// was reordered for reading, or a cover that overlaps the line's box but none of its words. +/// reported as hidden. Neighbouring runs are joined as the line joins them, so a word split +/// across runs stays one word; runs with others left out between them are spaced. The whole +/// line when its runs can't say: a single run, a line whose text was reordered for reading, or +/// a cover that overlaps the line's box but none of its words. fn covered_words(cover: &[f64; 4], line: &TextLine, out: &mut String) { let overlaps = |w: &[f64; 4]| area(&[cover[0].max(w[0]), cover[1].max(w[1]), cover[2].min(w[2]), cover[3].min(w[3])]) > 0.0; - let mut runs = line.runs().filter(|(text, ink)| !text.trim().is_empty() && ink.iter().any(overlaps)).peekable(); - if line.runs().nth(1).is_none() || runs.peek().is_none() { - out.push_str(line.text.trim()); - return; - } - for (i, (text, _)) in runs.enumerate() { - if i > 0 { - out.push(' '); + let mut words = String::new(); + let mut last = None; + for (i, (text, ink, spaced)) in line.runs().enumerate() { + if text.trim().is_empty() || !ink.iter().any(overlaps) { + continue; } - out.push_str(text.trim()); + let apart = last.is_some_and(|l: usize| l.saturating_add(1) != i || spaced); + if apart && !words.ends_with(char::is_whitespace) && !text.starts_with(char::is_whitespace) { + words.push(' '); + } + words.push_str(text); + last = Some(i); + } + if line.runs().nth(1).is_none() || last.is_none() { + out.push_str(line.text.trim()); + } else { + out.push_str(words.trim()); } } diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index f3edb0c0b..7908980f1 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -649,30 +649,26 @@ fn later_text_on_the_line_does_not_clear_an_earlier_cover() { /// line painted beside it, which stay visible. #[test] fn covered_text_leaves_out_the_words_beside_the_cover() { - let content = b"0 g -BT /F1 24 Tf 100 700 Td (SECRET) Tj ET -98 688 94 32 re f -BT /F1 24 Tf 200 700 Td (tail) Tj ET -"; - let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); - assert_eq!(findings.iter().map(|f| f.covered_text.as_str()).collect::>(), ["SECRET"]); + let covered = |content: &[u8]| { + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + findings.into_iter().map(|f| f.covered_text).collect::>() + }; + assert_eq!(covered(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"), ["SECRET"]); + // A word shown in two runs with nothing between them stays one word (review on #4198). + assert_eq!(covered(b"0 g\nBT /F1 24 Tf 100 700 Td (SE) Tj (CRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n"), ["SECRET"]); - // The same for an unapplied redaction mark over the first run only; a mark over both runs - // reports both. - for (rect, covered) in [("[98 688 192 720]", "SECRET"), ("[98 688 260 720]", "SECRET tail")] { + // The same for an unapplied redaction mark over the first run only. A mark over both runs + // reports both, with the space the line has between them (`tail` is set apart here). + for (rect, expected) in [("[98 688 192 720]", "SECRET"), ("[98 688 310 720]", "SECRET tail")] { 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 (SECRET) Tj ET -BT /F1 24 Tf 200 700 Td (tail) Tj ET -", - ); + b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\nBT /F1 24 Tf 250 700 Td (tail) Tj ET\n"); b.add(FONT); b.add(&format!("<< /Type /Annot /Subtype /Redact /Rect {rect} >>")); let findings = audit_redactions(&open(b.build())).expect("audit must run"); - assert_eq!(findings.iter().map(|f| (f.kind.clone(), f.covered_text.as_str())).collect::>(), [(FindingKind::UnappliedMark, covered)]); + assert_eq!(findings.iter().map(|f| (f.kind.clone(), f.covered_text.as_str())).collect::>(), [(FindingKind::UnappliedMark, expected)]); } } diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index fe4ff4202..4c9f1b882 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -80,9 +80,10 @@ pub struct TextLine { /// Why the line can't be edited: its font's writing mode (its CMap's `WMode`) couldn't be read, /// so which way its glyphs advance is unknown. The line is listed as horizontal text. unreadable_mode: Option, - /// The runs the line joins, in content order: each one's text and the end of its words in - /// `ink`. Empty when the line's text was put into another order (see [`TextLine::runs`]). - runs: Vec<(String, usize)>, + /// The runs the line joins, in content order: each one's text, the end of its words in + /// `ink`, and whether joining put a space before it. Empty when the line's text was put + /// into another order (see [`TextLine::runs`]). + runs: Vec<(String, usize, bool)>, } impl TextLine { @@ -542,17 +543,17 @@ impl TextLine { (self.stream, self.ops.iter().min().copied().unwrap_or(0)) } - /// The runs of text the line joins, in content order: each run's text as its glyphs show it - /// and the boxes of its words (a part of `ink`). Joining adds no space between runs, so a - /// run's text is not a word of `text` that can be found by position. None when the line's - /// text was reordered for reading (right-to-left or visual-order Indic text): then only the - /// line's text as a whole says what it reads. - pub fn runs(&self) -> impl Iterator { + /// The runs of text the line joins, in content order: each run's text as its glyphs show it, + /// the boxes of its words (a part of `ink`), and whether joining put a space between it and + /// the run before (`text` has one there; runs closer together join with none, so one word + /// can span runs). None when the line's text was reordered for reading (right-to-left or + /// visual-order Indic text): then only the line's text as a whole says what it reads. + pub fn runs(&self) -> impl Iterator { let mut start = 0; - self.runs.iter().map(move |(text, end)| { + self.runs.iter().map(move |(text, end, spaced)| { let ink = self.ink.get(start..*end).unwrap_or_default(); start = *end; - (text.as_str(), ink) + (text.as_str(), ink, *spaced) }) } @@ -1377,14 +1378,15 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> }); if joins && let Some(l) = lines.last_mut() { let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1); - if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { + let spaced = gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' '); + if spaced { l.text.push(' '); } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; l.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); - l.runs.push((s.text.clone(), l.ink.len())); + l.runs.push((s.text.clone(), l.ink.len(), spaced)); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; @@ -1417,7 +1419,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> actual_text: s.actual_text, clips: s.state.render_mode >= 4, unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned), - runs: vec![(s.text.clone(), s.ink.len())], + runs: vec![(s.text.clone(), s.ink.len(), false)], ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -1487,14 +1489,15 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream }); if joins && let Some(l) = lines.last_mut() { let gap = last.map_or(0.0, |pen| pen.continues(&s, 0.0).1); - if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { + let spaced = gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' '); + if spaced { l.text.push(' '); } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; l.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); - l.runs.push((s.text.clone(), l.ink.len())); + l.runs.push((s.text.clone(), l.ink.len(), spaced)); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; @@ -1526,7 +1529,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream actual_text: s.actual_text, clips: s.state.render_mode >= 4, unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned), - runs: vec![(s.text.clone(), s.ink.len())], + runs: vec![(s.text.clone(), s.ink.len(), false)], ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, From 52d49c301eab9a78f433b801f49a777b8e828150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Lu=C3=ADs=20da=20Silva?= Date: Fri, 23 Oct 2026 04:15:41 -0300 Subject: [PATCH 111/254] fix(audit): charge a line's runs to the comparison bound The covered text walks every run of a covered line, and a run can draw no words (`( ) Tj`), so a line now costs its word boxes or its runs, whichever are more. A word followed by many empty runs can no longer be walked once per cover outside the 50-million-comparison bound. Review on #4198 (CodeRabbit). --- crates/audit/src/lib.rs | 6 ++++-- crates/audit/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 46823a1fa..f2bcf6335 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -138,10 +138,12 @@ struct Budget { } impl Budget { - /// Resets the comparison count for a page with these `lines`. + /// Resets the comparison count for a page with these `lines`. A line costs its word boxes + /// or its runs, whichever are more (at least one): a cover check walks the boxes, and the + /// covered text walks the runs, which can draw no words at all (`( ) Tj`). 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))); + self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(line.runs().count()).max(1))); } /// The text `cover` hides in the `lines` that `hides` ([`covered_words`]), joined with single diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 7908980f1..e5fb9ab68 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -704,6 +704,28 @@ fn cover_and_line_comparisons_are_bounded() { assert!(crate::audit(&doc, limits).expect("within the budget").is_empty()); } +/// A run that draws no word still costs a comparison: the covered text walks every run of a +/// covered line, so a word followed by many `( ) Tj` must not be checked for free (review on +/// #4198). +#[test] +fn runs_without_words_count_against_the_comparison_bound() { + let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET) Tj".to_vec(); + content.extend_from_slice(&b" ( ) Tj".repeat(50)); + content.extend_from_slice( + b" ET +0 g +98 688 94 32 re f +", + ); + let doc = open(one_page(&content, "")); + let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text"); + assert_eq!(lines.iter().map(|l| (l.ink.len(), l.runs().count())).collect::>(), [(1, 51)]); + let limits = crate::Limits { comparisons: 50, covered_text: usize::MAX }; + assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); + let limits = crate::Limits { comparisons: 51, covered_text: usize::MAX }; + assert_eq!(crate::audit(&doc, limits).expect("within the budget").len(), 1); +} + /// 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] From 12b5e670e40ff321c7a00231a09b333205b8a15f Mon Sep 17 00:00:00 2001 From: so-sai Date: Thu, 22 Oct 2026 13:31:02 +0700 Subject: [PATCH 112/254] feat(ui): add 'Split document...' to page thumbnail context menus --- crates/ui-egui/src/canvas.rs | 6 +++ crates/ui-egui/src/editing.rs | 4 ++ crates/ui-egui/src/panels.rs | 7 +++ crates/ui-egui/tests/it/editing.rs | 82 ++++++++++++++++++++++++++++++ crates/ui-egui/tests/it/viewer.rs | 2 +- parity/acrobat-features.toml | 6 +-- 6 files changed, 103 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8df86fb3..df0c89130 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -665,6 +665,8 @@ pub enum ViewAction { Save, Extract, Split, + /// Split before each selected page: opens Split on that mode. + SplitBeforeSelected, /// Copy the selected pages (Cut also deletes them). CopyPages { cut: bool, @@ -5488,6 +5490,10 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool view.pending_action = Some(ViewAction::Extract); ui.close(); } + if ui.button(tl!("Split document…")).clicked() { + view.pending_action = Some(ViewAction::SplitBeforeSelected); + ui.close(); + } if ui.add_enabled(editable, egui::Button::new(tl!("Replace pages…"))).clicked() { view.pending_action = Some(ViewAction::ReplacePages); ui.close(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 5b821b638..99f0534ab 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -265,6 +265,10 @@ impl PdfCraftApp { } Some(crate::canvas::ViewAction::Extract) => self.open_extract_dialog(), Some(crate::canvas::ViewAction::Split) => self.dialog = Some(crate::Dialog::Split), + Some(crate::canvas::ViewAction::SplitBeforeSelected) => { + self.split_draft.mode = crate::SplitMode::Selection; + self.dialog = Some(crate::Dialog::Split); + } Some(crate::canvas::ViewAction::CopyPages { cut }) => self.copy_pages(cut), Some(crate::canvas::ViewAction::PastePages) => self.paste_pages(), Some(crate::canvas::ViewAction::EnhanceCamera) => self.enhance_camera(i), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index edfe63ebc..d36f4cf19 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1363,6 +1363,13 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie view.pending_action = Some(crate::canvas::ViewAction::Extract); ui.close(); } + if ui.button(tl!("Split document…")).clicked() { + if view.selected.is_empty() { + view.select_pages(&[i]); + } + view.pending_action = Some(crate::canvas::ViewAction::SplitBeforeSelected); + ui.close(); + } if ui.add_enabled(editable, egui::Button::new(tl!("Replace pages…"))).clicked() { view.pending_action = Some(crate::canvas::ViewAction::ReplacePages); ui.close(); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 71ba9ccda..94e6d62bc 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -2241,6 +2241,88 @@ fn split_before_selected_pages() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn split_from_organize_grid_context_menu() { + let dir = temp_path("split-org-ctx"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut h = organize(4); + h.state_mut().export_dir_override = Some(dir.to_string_lossy().into_owned()); + h.get_by_label("Page 3").click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection); + h.get_by_label_contains("Creates 2 files from 4 pages"); + h.get_all_by_label("Split").last().unwrap().click(); + h.run_steps(3); + let mut names: Vec = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + assert_eq!(names, ["doc (pages 1-2).pdf", "doc (pages 3-4).pdf"]); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn split_from_pages_panel_context_menu() { + let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + let thumb = h.get_all_by_label("Page 3").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap(); + thumb.click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection); + h.get_by_label_contains("Creates 2 files from 4 pages"); +} + +#[test] +fn split_from_pages_panel_context_menu_on_current_page_without_selection() { + let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + // Navigate to Page 2 (0-indexed 1) so it is current, but keep selected empty + h.state_mut().views[0].go_to_page(1); + h.state_mut().views[0].selected.clear(); + h.run_steps(2); + assert_eq!(h.state().views[0].current, 1); + assert!(h.state().views[0].selected.is_empty()); + let thumb = h.get_all_by_label("Page 2").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap(); + thumb.click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection); + h.get_by_label_contains("Creates 2 files from 4 pages"); +} + +#[test] +fn split_from_pages_panel_context_menu_on_page_one_falls_back_to_pages() { + let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + let thumb = h.get_all_by_label("Page 1").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap(); + thumb.click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Pages); +} + +#[test] +fn split_from_pages_panel_context_menu_with_multi_selection() { + let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + // Select pages 2 and 4 (indices 1 and 3) + h.state_mut().views[0].select_pages(&[1, 3]); + h.run_steps(2); + let thumb = h.get_all_by_label("Page 2").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap(); + thumb.click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection); + h.get_by_label_contains("Creates 3 files from 4 pages"); +} + // ── Encrypted documents ─────────────────────────────────────────────────────────────────────── fn protected(user: &str, owner: &str, permissions: i32) -> Vec { diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index a3c86fc52..e4a18ba5e 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -1393,7 +1393,7 @@ fn pages_panel_thumbnails_have_a_context_menu() { h.run_steps(2); // The page right-clicked becomes the selection the menu acts on. assert_eq!(h.state().views[0].target_pages(), vec![2]); - for item in ["Extract pages", "Cut", "Paste after"] { + for item in ["Extract pages", "Split document…", "Cut", "Paste after"] { h.get_by_label(item); } h.get_by_label("Copy").click(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6a2d0ccf9..23c6d5fed 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1274,8 +1274,8 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/it/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/it/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"] -notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." +evidence = ["crates/ui-egui/tests/it/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/it/editing.rs::split_from_pages_panel_context_menu", "crates/ui-egui/tests/it/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/it/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"] +notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Split document…, Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." [[feature]] id = "view.bookmarks-panel" @@ -2920,7 +2920,7 @@ milestone = "M4" status = "shipped" commands = ["page.split"] tools = ["doc_split"] -evidence = ["crates/ui-egui/tests/it/editing.rs::split_before_selected_pages"] +evidence = ["crates/ui-egui/tests/it/editing.rs::split_before_selected_pages", "crates/ui-egui/tests/it/editing.rs::split_from_organize_grid_context_menu"] notes = "" [[feature]] From 833d6546f08ffb47d419bcc9ce668faa9f6c5944 Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Fri, 23 Oct 2026 09:40:50 +0200 Subject: [PATCH 113/254] optimize: the JPEG header scan stops at the first stray byte (#1201 review) Optimize reads a JPEG's header again for every group of fragments it tries, and one image can be drawn many times. The scan skipped stray bytes between segments one at a time, so a JPEG with megabytes of them after SOI cost a full walk on every call: 64 references to one such image delayed the first progress call by seconds. The scan now treats a stray byte as an unreadable header, which leaves the image as it is, and stops after 4096 markers and fill bytes. Each step jumps over a whole segment, so the work no longer grows with the stream's size. --- crates/optimize/src/images.rs | 23 +++++--- crates/optimize/tests/jpeg_color_transform.rs | 54 ++++++++++++++++++- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 1a9a918ac..ef65634d0 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -286,21 +286,31 @@ fn color_transform(doc: &Document, s: &Stream) -> Option> { } } +/// Markers and fill bytes [`adobe_transform`] reads before the first scan, at most. Encoders +/// write a few dozen segments. +const MAX_HEADER_STEPS: usize = 4096; + /// 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. +/// without one. `None` if the segments before the first scan can't be read, which includes a +/// stray byte between segments and a header of more than [`MAX_HEADER_STEPS`] steps. +/// +/// Each step reads one marker or fill byte and jumps over its segment, so the work is bounded +/// by the step count, not by the size of the stream. That matters: Optimize asks again for every +/// group of fragments it tries, and the same image can be drawn many times. 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 { + // Reading past the end returns. + for _ in 0..MAX_HEADER_STEPS { + // Decoders skip stray bytes between segments, but a stream can hold megabytes of them + // and each would be a step: the header counts as unreadable and the image is left as + // it is. if *data.get(i)? != 0xFF { - // Stray bytes between segments, which decoders skip. - i = i.checked_add(1)?; - continue; + return None; } let marker = *data.get(i.checked_add(1)?)?; match marker { @@ -325,6 +335,7 @@ fn adobe_transform(data: &[u8]) -> Option> { } } } + None } /// 8-bit samples of a JPEG `w` × `h` with `n` components (grey or RGB): YCbCr converted to RGB diff --git a/crates/optimize/tests/jpeg_color_transform.rs b/crates/optimize/tests/jpeg_color_transform.rs index 77a329352..a84dea1cd 100644 --- a/crates/optimize/tests/jpeg_color_transform.rs +++ b/crates/optimize/tests/jpeg_color_transform.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; -use pdfcraft_optimize::{Settings, optimize}; +use pdfcraft_optimize::{OptimizeError, Settings, Stage, optimize, optimize_with_progress}; /// 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. @@ -361,3 +361,55 @@ fn merging_jpeg_strips_keeps_their_colour_transform() { let difference = mean_difference(&before, &render(&doc, 1.0)); assert!(difference <= TOLERANCE, "colours moved by {difference:.1}"); } + +/// `jpeg` with `bytes` right after its SOI marker. +fn after_soi(jpeg: &[u8], bytes: &[u8]) -> Vec { + let mut v = jpeg[..2].to_vec(); + v.extend_from_slice(bytes); + v.extend_from_slice(&jpeg[2..]); + v +} + +#[test] +fn a_jpeg_header_that_takes_more_than_a_few_steps_to_read_is_left_as_it_is() { + // Decoders skip stray bytes between segments and any number of fill bytes before a marker, + // so a stream can make a header scan walk megabytes. Optimize reads the header again for + // every group of fragments it tries: it stops at the first stray byte, or after a few + // thousand steps, and leaves such an image as it is. + let (w, h) = (128, 128); + let plain = rgb_jpeg(&photo(w, h), w, h); + for (what, jpeg, expect) in [ + ("three fill bytes before the first segment", after_soi(&plain, &[0xFF; 3]), Outcome::Reencoded), + ("one stray byte before the first segment", after_soi(&plain, &[0]), Outcome::Untouched), + ("8 MB of stray bytes before the first segment", after_soi(&plain, &vec![0; 8_000_000]), Outcome::Untouched), + ("10,000 fill bytes before the first segment", after_soi(&plain, &[0xFF; 10_000]), Outcome::Untouched), + ] { + let (mut doc, img) = showing(w, h, 72, "DeviceRGB", jpeg, Parms::None); + let original = stream(&doc, img); + optimize(&mut doc, &Settings::default()).unwrap(); + let outcome = if stream(&doc, img) == original { Outcome::Untouched } else { Outcome::Reencoded }; + assert_eq!(outcome, expect, "{what}"); + } +} + +#[test] +fn cancelling_works_from_the_first_call_with_a_jpeg_drawn_many_times() { + // One JPEG with 8 MB of stray bytes after SOI, drawn 64 times at the same place (the case + // reported on #3012). Fragment merging reads its header for each group it tries, before the + // first progress call. The header scan stops at the first stray byte, so that call is the + // first image, and cancelling there stops Optimize. + let (w, h) = (128, 8); + let jpeg = after_soi(&rgb_jpeg(&photo(w, h), w, h), &vec![0; 8_000_000]); + let mut doc = Document::new_empty(); + let img = jpeg_image(&mut doc, w, h, "DeviceRGB", jpeg, Parms::None); + let content = format!("q {w} 0 0 {h} 0 0 cm /Im Do Q +").repeat(64); + one_page(&mut doc, w, h, &[("Im".to_string(), img)], &content); + let mut stages = Vec::new(); + let result = optimize_with_progress(&mut doc, &Settings::default(), &mut |stage| { + stages.push(stage); + false + }); + assert!(matches!(result, Err(OptimizeError::Cancelled)), "{result:?}"); + assert_eq!(stages, [Stage::Images { done: 0, total: 1 }]); +} From 35b46770195fd961f2bb51b82454c4a2d495aa7d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 09:41:41 +0200 Subject: [PATCH 114/254] M11: LZW to Flate leaves streams whose decode parameters aren't direct integers --- crates/automation/src/tools.rs | 2 +- crates/optimize/README.md | 2 +- crates/optimize/src/lib.rs | 7 +++++++ crates/optimize/tests/optimize.rs | 14 ++++++++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 028d58d68..70a028c13 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1359,7 +1359,7 @@ 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); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode (lzw_streams); 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. 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); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); 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!({ diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 387c26cb0..06edee534 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -30,7 +30,7 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going - **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 - bytes). Streams with `/DecodeParms`, images and object streams are left alone. LZW-compressed + bytes; streams with `/DecodeParms`, images and object streams keep their ASCII layer. LZW-compressed streams, content and images alike, are decoded and stored with Flate instead (Acrobat's "In streams that use LZW encoding, use Flate instead"): a predictor carries over to Flate without `EarlyChange`, filters applied before the LZW one go with it and those after it keep their diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 14472106a..5c3155485 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -370,6 +370,13 @@ fn lzw_to_flate(s: &Stream, max: usize) -> Option { let chain = s.filters(); let stages = chain.get(..=lzw)?; let (_, params) = stages.last()?; + // The decoded stages' parameters must be direct integers: `filters` reads anything else (a + // reference, 12.5, a name) as the default, and the dictionary carried over would then + // describe the new data differently from how it was encoded. + let direct = |p: &Option<&Dict>| p.is_none_or(|d| d.iter().all(|(_, v)| matches!(v, Object::Int(_)))); + if !parms.get(..=lzw)?.iter().all(direct) { + return None; + } let data = pdfcraft_filters::decode(stages, &s.raw, max).ok()?; let flate = Params { early_change: 1, ..params.clone() }; let raw = pdfcraft_filters::encode(&Filter::Flate, &flate, &data).ok()?; diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index b0049a1c6..e78571c1e 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1271,6 +1271,18 @@ fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() { bad_predictor.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); let mut indirect_parms = filtered(&["LZWDecode"], good.clone()); indirect_parms.dict.set(b"DecodeParms".to_vec(), Object::Ref(ObjRef { num: 999, generation: 0 })); + // Columns the reader can't take as an integer: read as the default 1 here, so the data is + // predicted with one column and decodes, but a viewer resolving the value would use another. + let one_column = Params { predictor: 12, ..Params::default() }; + let odd_columns = |columns: Object| { + let mut s = filtered(&["LZWDecode"], lzw(&ops, &one_column)); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), columns); + s.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + s + }; + assert_eq!(odd_columns(Object::Real(12.5)).decoded_strict().unwrap(), ops, "read with one column"); let mut objstm = filtered(&["LZWDecode"], good.clone()); objstm.dict.set(b"Type".to_vec(), Object::name("ObjStm")); let mut external = filtered(&["LZWDecode"], good.clone()); @@ -1281,6 +1293,8 @@ fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() { ("indirect DecodeParms", indirect_parms, Settings::default()), ("external file", external, Settings::default()), ("object stream, rewritten on save", objstm, Settings::default()), + ("Columns by reference", odd_columns(Object::Ref(ObjRef { num: 9, generation: 0 })), Settings::default()), + ("Columns not an integer", odd_columns(Object::Real(12.5)), Settings::default()), ("crypt filter", filtered(&["Crypt", "LZWDecode"], good.clone()), Settings::default()), ("option off", filtered(&["LZWDecode"], good.clone()), Settings { lzw_to_flate: false, ..Settings::default() }), ]; From d09ccb2154d167962407df113e2a7d4d57523c41 Mon Sep 17 00:00:00 2001 From: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:55:12 -0400 Subject: [PATCH 115/254] i18n(ui): carry this PR's new wordings in the catalogues that must cover them Catalan, Finnish, Hindi, Korean, Indonesian, Romanian and Albanian get the strings this PR adds (AI-assisted translations from English, not separately native-reviewed), so the catalogue coverage tests pass. ATTRIBUTION.toml's sha256 for each file is refreshed. Co-Authored-By: Claude Sonnet 5.5 --- ATTRIBUTION.toml | 14 +++++++------- crates/ui-egui/src/i18n/ca.tsv | 5 +++++ crates/ui-egui/src/i18n/fi.tsv | 5 +++++ crates/ui-egui/src/i18n/hi.tsv | 5 +++++ crates/ui-egui/src/i18n/id.tsv | 5 +++++ crates/ui-egui/src/i18n/ko.tsv | 5 +++++ crates/ui-egui/src/i18n/ro.tsv | 5 +++++ crates/ui-egui/src/i18n/sq.tsv | 5 +++++ 8 files changed, 42 insertions(+), 7 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 897549ead..648a562f8 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -261,7 +261,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "09d2840c02b40a6049b86c2cb68151d863913e8c91c22226f25d07496c6366aa" +sha256 = "e80548e47936ef352d36d7528855221f5d4f7e101633ac99c7edaaef7511e4f8" [[asset]] path = "crates/ui-egui/src/i18n/id.tsv" title = "Indonesian interface translation catalog" @@ -271,7 +271,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0dc1abdb262e4568f19ffb2233bfeb3686ae42df8a8d8bec2951351a50e6a886" +sha256 = "ee3be7369276cb11a73bbab3b8fe077e8eb51742f3f45c6a0244d91f57b77475" [[asset]] path = "crates/ui-egui/src/i18n/ko.tsv" title = "Korean interface translation catalog" @@ -281,7 +281,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a3e67cee4f3fff776e816523f810b2bb6353235dbdf85181c3f12896604a3339" +sha256 = "a7e1e7416f445d46795efb644de6c3fcac7e756265463853edfb03b341c6b708" [[asset]] path = "crates/ui-egui/src/i18n/ro.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 = "00a2153c20a191fbe04f1ce61f140fd9628cfe2f8f75dad5bce8a7406e1bd091" +sha256 = "c2c03bc8c7baaf1d7bc2ffc2c3cb7f092bdc35b689330d912251d43181dc89f4" [[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 = "609efa92054aa76de4bb6062c0fcc0a8b450519f4b253b4c0f395b3cbb34d2ba" +sha256 = "df64f942d5017971b1d93afff9498c5b29c3688ca012127c5692829e121252c0" [[asset]] path = "assets/icons/accessibility.svg" @@ -3515,7 +3515,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ebd177262d8fe20f960f06df084f8a03c9504bcd9990dded3a006e35bd8ef7c9" +sha256 = "385382e858b826b76fba14886ceb23cf9f00ab556cafa4baa95c25701787189d" [[asset]] path = "crates/ui-egui/src/i18n/th.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 = "5879ee3540ffb630fc83d0f2cb293cc6cbf2e081ea4577161a770794e8cbd22c" +sha256 = "f08a77efcccc52d852b8f0bc983db6557161a0fc6602af0f298f83c14839f5b3" diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index dd2f497b8..cb1c52f2f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2774,3 +2774,8 @@ theme Appearance Aspecte field names can't contain a period: {name} els noms dels camps no poden contenir cap punt: {name} two cells would both be named {name} dues cel·les es dirien {name} a field named {name} already exists ja existeix un camp anomenat {name} + Attach a file… Adjunta un fitxer… + Attach {name} Adjunta {name} + Attaching files is not available on the web yet Encara no es poden adjuntar fitxers al web + Delete attachment Elimina l'adjunt + Delete attachment {key} Elimina l'adjunt {key} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index efcf3e3c6..7c550f2d8 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2783,3 +2783,8 @@ theme Appearance Teema field names can't contain a period: {name} kenttien nimissä ei voi olla pistettä: {name} two cells would both be named {name} kahdella solulla olisi sama nimi {name} a field named {name} already exists kenttä nimeltä {name} on jo olemassa + Attach a file… Liitä tiedosto… + Attach {name} Liitä {name} + Attaching files is not available on the web yet Tiedostojen liittäminen ei ole vielä käytettävissä verkossa + Delete attachment Poista liite + Delete attachment {key} Poista liite {key} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7be5ec688..3ba979d2d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2750,3 +2750,8 @@ theme Appearance स्वरूप field names can't contain a period: {name} फ़ील्ड के नामों में पूर्ण विराम (.) नहीं हो सकता: {name} two cells would both be named {name} दो सेल का नाम {name} हो जाएगा a field named {name} already exists {name} नाम का फ़ील्ड पहले से मौजूद है + Attach a file… फ़ाइल संलग्न करें… + Attach {name} {name} संलग्न करें + Attaching files is not available on the web yet वेब पर अभी फ़ाइलें संलग्न करना उपलब्ध नहीं है + Delete attachment अनुलग्नक हटाएँ + Delete attachment {key} अनुलग्नक {key} हटाएँ diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3585b344e..620315ba4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2751,3 +2751,8 @@ theme Appearance Tampilan field names can't contain a period: {name} nama kolom tidak boleh berisi titik: {name} two cells would both be named {name} dua sel akan sama-sama bernama {name} a field named {name} already exists kolom bernama {name} sudah ada + Attach a file… Lampirkan file… + Attach {name} Lampirkan {name} + Attaching files is not available on the web yet Melampirkan file belum tersedia di web + Delete attachment Hapus lampiran + Delete attachment {key} Hapus lampiran {key} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 48ef31e19..ba0359ee4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2750,3 +2750,8 @@ theme Appearance 모양 field names can't contain a period: {name} 필드 이름에는 마침표를 쓸 수 없습니다: {name} two cells would both be named {name} 두 셀의 이름이 모두 {name}이(가) 됩니다 a field named {name} already exists {name}(이)라는 필드가 이미 있습니다 + Attach a file… 파일 첨부… + Attach {name} {name} 첨부 + Attaching files is not available on the web yet 웹에서는 아직 파일을 첨부할 수 없습니다 + Delete attachment 첨부 파일 삭제 + Delete attachment {key} 첨부 파일 {key} 삭제 diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 0003e3851..63296c7a7 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2774,3 +2774,8 @@ theme Appearance Aspect field names can't contain a period: {name} numele câmpurilor nu pot conține punct: {name} two cells would both be named {name} două celule s-ar numi amândouă {name} a field named {name} already exists există deja un câmp numit {name} + Attach a file… Atașare fișier… + Attach {name} Atașare {name} + Attaching files is not available on the web yet Atașarea fișierelor nu este încă disponibilă pe web + Delete attachment Ștergere atașament + Delete attachment {key} Ștergere atașament {key} diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index c93efee5a..df40b1427 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2500,3 +2500,8 @@ animation effects Off Joaktiv field names can't contain a period: {name} emrat e fushave nuk mund të përmbajnë pikë: {name} two cells would both be named {name} dy qeliza do të kishin të dyja emrin {name} a field named {name} already exists ekziston tashmë një fushë me emrin {name} + Attach a file… Bashkëngjit një skedar… + Attach {name} Bashkëngjit {name} + Attaching files is not available on the web yet Bashkëngjitja e skedarëve nuk është ende e disponueshme në ueb + Delete attachment Fshi bashkëngjitjen + Delete attachment {key} Fshi bashkëngjitjen {key} From 4bd1e72788b8d0d60e9a60936cd270f3e847f058 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:13:15 +0300 Subject: [PATCH 116/254] Refuse an OCR batch whose inputs would write one output file The ocr_recognize_files tool wrote every input's searchable copy to the same name in the output folder. Two inputs with one file name (a/scan.pdf and b/scan.pdf) therefore wrote the same output: the second replaced the first while both results reported success. Reproduced on the unchanged tree with the OCR models installed: both results pointed at out/scan.pdf, and the folder held one file. The whole batch is now refused before anything happens. The output folder is not created, nothing is read, and the OCR engines are not loaded. The error names the colliding file names and says what to do. Names are compared the way the default filesystem compares them: without regard to case on Windows and macOS, exactly elsewhere, and on Windows a trailing dot or space is not part of the name. The compared name is the one the output is written under, after the separator replacement the writer already applies. A request without collisions runs as before. Not changed here: the batch dialog in crates/ui-egui (ocr_ui.rs) writes its outputs the same way and needs its own change. Checked: the three ocr_recognize_files tests in crates/automation/tests/automation.rs and the unit test shared_output_names_follow_the_default_filesystem, run with the models installed and without them. Each pinned line was reverted in turn and the test that pins it failed; the file was restored after each step. --- crates/automation/src/a11y.rs | 61 +++++++++++++++++++++++- crates/automation/tests/it/automation.rs | 54 +++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 21bde353e..833e5de9f 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -601,11 +601,22 @@ impl Automation { for p in &paths { self.resolve_for(p, crate::file_access::Access::Read, true)?; } + // Two inputs that would write the same output file overwrite each other while both report + // success, so the whole batch is refused here: before the folder is created, anything is + // read, or the OCR engines load. + let targets: Vec = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect(); + let shared = shared_file_names(&targets); + if !shared.is_empty() { + return Err(ToolError::InvalidArgs(format!( + "more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.", + shared.join(", ") + ))); + } std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?; let mut out = Vec::new(); 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 name = input_file_name(p); 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) @@ -650,6 +661,36 @@ impl Automation { } } +/// The file name an input is written under: its own file name, or `document.pdf` when it has none. +fn input_file_name(path: &str) -> String { + std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()) +} + +/// The output file names that two or more of `targets` share, each spelling listed once, in input +/// order. Names are compared the way the default filesystem compares them: without regard to case +/// on Windows and macOS and exactly elsewhere; on Windows a trailing dot or space is not part of +/// the name (`scan.pdf.` is `scan.pdf`). The folder is the same for every target, so only the +/// names are compared. +fn shared_file_names(targets: &[std::path::PathBuf]) -> Vec { + let mut groups: std::collections::BTreeMap)> = std::collections::BTreeMap::new(); + for target in targets { + let name = target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let key = if cfg!(windows) { + name.trim_end_matches(['.', ' ']).to_lowercase() + } else if cfg!(target_os = "macos") { + name.to_lowercase() + } else { + name.clone() + }; + let (count, spellings) = groups.entry(key).or_insert_with(|| (0, Vec::new())); + *count += 1; + if !spellings.contains(&name) { + spellings.push(name); + } + } + groups.into_values().filter(|(count, _)| *count > 1).flat_map(|(_, spellings)| spellings).collect() +} + fn request_json(r: &pdfcraft_engine::js::Request) -> Value { use pdfcraft_engine::js::Request as R; match r { @@ -1042,3 +1083,21 @@ mod review_html_tests { assert!(!html.contains("