From b31b40356dfcbd6c1170e1fb16e1f83f040867e8 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 17:54:15 +0530 Subject: [PATCH 01/16] fix(ui): prevent wheel lockup at edge in non-continuous view (#5713) --- crates/ui-egui/src/canvas/navigation.rs | 7 +++- crates/ui-egui/src/canvas/wheel_pager.rs | 5 +++ crates/ui-egui/tests/it/view.rs | 42 ++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 731b2c2586..98be96a72a 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -268,7 +268,11 @@ impl DocView { let Some(forward) = self.wheel.feed(unit, dy, phase, now, can_turn) else { return false }; let before = self.current; self.step_page(forward); - self.current != before + if self.current == before { + self.wheel.unturn(); + return false; + } + true } /// One wheel event in a non-continuous view whose page is taller than the window, where the @@ -288,6 +292,7 @@ impl DocView { let before = self.current; self.step_page(forward); if self.current == before { + self.wheel.unturn(); return false; } if !forward { diff --git a/crates/ui-egui/src/canvas/wheel_pager.rs b/crates/ui-egui/src/canvas/wheel_pager.rs index d12c445a8a..adabb8bee1 100644 --- a/crates/ui-egui/src/canvas/wheel_pager.rs +++ b/crates/ui-egui/src/canvas/wheel_pager.rs @@ -73,6 +73,11 @@ impl WheelPager { self.accum = 0.0; } + /// Reset any turned state when a turn did not happen (e.g. at document boundary). + pub(crate) fn unturn(&mut self) { + self.turned = None; + } + /// Whether the current gesture already turned a page: the rest of it must not pan the page /// just shown away from its top (or bottom). pub(crate) fn gesture_turned(&self) -> bool { diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 7f444bee0f..584cb064cb 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -520,6 +520,48 @@ fn single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does() ); } +#[test] +fn single_page_wheel_at_last_page_bottom_edge_can_scroll_back_up() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]); + h.state_mut().views[0].go_to_page(2); // Go to last page of PAGES (3 pages) + h.run_steps(20); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(4); + + // Scroll to the bottom edge of page 3 + let mut notches = 0; + let mut last = rect(&h, 2).expect("page 3 on screen").min.y; + let mut at_bottom = false; + while notches < 80 { + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + h.run_steps(12); + notches += 1; + if let Some(r) = rect(&h, 2) { + if (r.min.y - last).abs() < 0.01 { + at_bottom = true; + break; + } + last = r.min.y; + } + } + assert!(at_bottom, "reached bottom edge of last page"); + assert_eq!(h.state().views[0].current, 2, "remains on page 3"); + + // Push the wheel further against the bottom edge (cannot turn because it's the last page) + for _ in 0..5 { + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + h.run_steps(12); + } + let bottom_y = rect(&h, 2).expect("page 3 on screen").min.y; + + // Roll the wheel back up: the page must move away from the bottom edge (#5713) + h.event(wheel(MouseWheelUnit::Line, 1.0, TouchPhase::Move)); + h.run_steps(12); + let after_up_y = rect(&h, 2).expect("page 3 on screen").min.y; + assert!(after_up_y > bottom_y, "scrolling up moves the page away from the bottom edge: {after_up_y} > {bottom_y}"); +} + #[test] fn stepping_past_either_end_leaves_the_view_alone() { // Paging on past the first or last page must not jump the page back to its top. From 08af1dc39fe5ccff14f429f1087d1cc860d87ed1 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Mon, 2 Nov 2026 18:33:38 +0530 Subject: [PATCH 02/16] fix(ui): end a notch's turn when the next notch only pans (#5713) A wheel notch sets `turned`, and only a trackpad touch or a smooth burst cleared it. With a wheel that macOS reports in points, a notch that turned the page left `gesture_turned` set, so read_wheel swallowed every later notch on that page. The same happened at the last page's bottom edge. A notch that cannot turn now clears `turned`. The new tests use Point notches. The Line-unit test in b31b4035 passes without either fix, because egui's own ScrollArea still applies line scrolling. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/wheel_pager.rs | 3 ++ crates/ui-egui/tests/it/view.rs | 50 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/crates/ui-egui/src/canvas/wheel_pager.rs b/crates/ui-egui/src/canvas/wheel_pager.rs index adabb8bee1..c8ce69c57d 100644 --- a/crates/ui-egui/src/canvas/wheel_pager.rs +++ b/crates/ui-egui/src/canvas/wheel_pager.rs @@ -149,6 +149,9 @@ impl WheelPager { }; if notch { if !can_turn { + // A notch is a gesture of its own, so one that only pans ends the gesture a + // previous notch turned; otherwise `gesture_turned` keeps swallowing the wheel (#5713). + self.turned = None; return None; } // Every notch turns a page; a smooth tail of the same gesture won't add another. diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 584cb064cb..6f29132c28 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -562,6 +562,56 @@ fn single_page_wheel_at_last_page_bottom_edge_can_scroll_back_up() { assert!(after_up_y > bottom_y, "scrolling up moves the page away from the bottom edge: {after_up_y} > {bottom_y}"); } +/// One notch of a mouse wheel that macOS reports in points (a Logitech mouse sends about 14). +const POINT_NOTCH: f32 = 14.0; + +#[test] +fn single_page_point_notches_pan_the_page_a_notch_turned_to() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(20); + for _ in 0..120 { + h.event(wheel(MouseWheelUnit::Point, -POINT_NOTCH, TouchPhase::Move)); + h.run_steps(12); + if h.state().views[0].current == 1 { + break; + } + } + assert_eq!(h.state().views[0].current, 1, "notches pan page 1, then turn to page 2"); + h.run_steps(20); + let top = rect(&h, 1).expect("page 2 on screen").min.y; + for _ in 0..3 { + h.event(wheel(MouseWheelUnit::Point, -POINT_NOTCH, TouchPhase::Move)); + h.run_steps(12); + } + let after = rect(&h, 1).expect("page 2 on screen").min.y; + assert!(after < top, "notches pan page 2 down after the turn (#5713): {after} < {top}"); +} + +#[test] +fn single_page_point_notches_scroll_back_up_from_the_last_page_bottom() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]); + h.state_mut().views[0].go_to_page(2); + h.run_steps(20); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(4); + // Past the bottom: the extra notches push against the last page's edge. + for _ in 0..200 { + h.event(wheel(MouseWheelUnit::Point, -POINT_NOTCH, TouchPhase::Move)); + h.run_steps(12); + } + assert_eq!(h.state().views[0].current, 2, "stays on the last page"); + let bottom = rect(&h, 2).expect("page 3 on screen").min.y; + for _ in 0..3 { + h.event(wheel(MouseWheelUnit::Point, POINT_NOTCH, TouchPhase::Move)); + h.run_steps(12); + } + let after = rect(&h, 2).expect("page 3 on screen").min.y; + assert!(after > bottom + 1.0, "notches scroll back up from the last page's bottom (#5713): {after} > {bottom}"); +} + #[test] fn stepping_past_either_end_leaves_the_view_alone() { // Paging on past the first or last page must not jump the page back to its top. From 3936f93a1479366a951206fc6d3094680fe2d3dc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 19:26:43 -0500 Subject: [PATCH 03/16] Release 0.18.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 72 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 68 +++++++++++++++++++++++++-------------------------- 2 files changed, 70 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d2f4dadad0..c46eac4790 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4208,7 +4208,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.17.0" +version = "0.18.0" dependencies = [ "chrono", "craft-update", @@ -4232,7 +4232,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.17.0" +version = "0.18.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4244,7 +4244,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.17.0" +version = "0.18.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4255,7 +4255,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.17.0" +version = "0.18.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4266,7 +4266,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.17.0" +version = "0.18.0" dependencies = [ "base64 0.23.1", "flate2", @@ -4288,7 +4288,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.17.0" +version = "0.18.0" dependencies = [ "base64 0.23.1", "pdfcraft-audit", @@ -4306,11 +4306,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.17.0" +version = "0.18.0" [[package]] name = "pdfcraft-content" -version = "0.17.0" +version = "0.18.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4318,7 +4318,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.17.0" +version = "0.18.0" dependencies = [ "chrono", "hayro-syntax", @@ -4333,7 +4333,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.17.0" +version = "0.18.0" dependencies = [ "encoding_rs", "hayro-ccitt", @@ -4352,7 +4352,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.17.0" +version = "0.18.0" dependencies = [ "aes", "md-5", @@ -4364,7 +4364,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.17.0" +version = "0.18.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4382,7 +4382,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.17.0" +version = "0.18.0" dependencies = [ "chrono", "craft-docpath", @@ -4426,14 +4426,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.17.0" +version = "0.18.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.17.0" +version = "0.18.0" dependencies = [ "fax", "flate2", @@ -4446,7 +4446,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.17.0" +version = "0.18.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4456,7 +4456,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.17.0" +version = "0.18.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4469,11 +4469,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.17.0" +version = "0.18.0" [[package]] name = "pdfcraft-js" -version = "0.17.0" +version = "0.18.0" dependencies = [ "boa_engine", "chrono", @@ -4481,7 +4481,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.17.0" +version = "0.18.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4494,14 +4494,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.17.0" +version = "0.18.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.17.0" +version = "0.18.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4512,7 +4512,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.17.0" +version = "0.18.0" dependencies = [ "image", "pdfcraft-annot", @@ -4528,11 +4528,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.17.0" +version = "0.18.0" [[package]] name = "pdfcraft-organize" -version = "0.17.0" +version = "0.18.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4543,7 +4543,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.17.0" +version = "0.18.0" dependencies = [ "async-channel", "futures-lite", @@ -4553,7 +4553,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.17.0" +version = "0.18.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4564,7 +4564,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.17.0" +version = "0.18.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4576,7 +4576,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.17.0" +version = "0.18.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4593,7 +4593,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.17.0" +version = "0.18.0" dependencies = [ "bytemuck", "flate2", @@ -4612,7 +4612,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.17.0" +version = "0.18.0" dependencies = [ "aes", "aws-lc-rs", @@ -4648,11 +4648,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.17.0" +version = "0.18.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.17.0" +version = "0.18.0" dependencies = [ "arboard", "base64 0.23.1", @@ -4706,7 +4706,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.17.0" +version = "0.18.0" dependencies = [ "eframe", "egui", @@ -4718,7 +4718,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.17.0" +version = "0.18.0" dependencies = [ "base64 0.23.1", "image", @@ -4732,7 +4732,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.17.0" +version = "0.18.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index edd9e682b7..7be5624db7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.17.0" +version = "0.18.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -26,39 +26,39 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.17.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.17.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.17.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.17.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.17.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.17.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.17.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.17.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.17.0" } -pdfcraft-model = { path = "crates/model", version = "0.17.0" } -pdfcraft-content = { path = "crates/content", version = "0.17.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.17.0" } -pdfcraft-print = { path = "crates/print", version = "0.17.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.17.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.17.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.17.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.17.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.17.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.17.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.17.0" } -pdfcraft-js = { path = "crates/js", version = "0.17.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.17.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.17.0" } -pdfcraft-export = { path = "crates/export", version = "0.17.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.17.0" } -pdfcraft-create = { path = "crates/create", version = "0.17.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.17.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.17.0" } -pdfcraft-render = { path = "crates/render", version = "0.17.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.17.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.17.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.17.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.17.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.18.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.18.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.18.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.18.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.18.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.18.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.18.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.18.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.18.0" } +pdfcraft-model = { path = "crates/model", version = "0.18.0" } +pdfcraft-content = { path = "crates/content", version = "0.18.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.18.0" } +pdfcraft-print = { path = "crates/print", version = "0.18.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.18.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.18.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.18.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.18.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.18.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.18.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.18.0" } +pdfcraft-js = { path = "crates/js", version = "0.18.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.18.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.18.0" } +pdfcraft-export = { path = "crates/export", version = "0.18.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.18.0" } +pdfcraft-create = { path = "crates/create", version = "0.18.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.18.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.18.0" } +pdfcraft-render = { path = "crates/render", version = "0.18.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.18.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.18.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.18.0", default-features = false } # pdf hayro = "0.7.1" From 69ddcd89bfc6cee3428cfd0f9bcde835adcc58c3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 23:31:39 -0500 Subject: [PATCH 04/16] fix(redact): rewritten form XObjects drop /PCAdded (#5868) Page content streams rewritten by redaction or hidden-content removal already dropped /PCAdded, the source text of an added item, but a form XObject whose content was rewritten kept its original dictionary, /PCAdded included, so the removed text stayed readable in the saved file. --- crates/redact/src/interp.rs | 5 +++++ crates/redact/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 5f02be7fd0..460085a320 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -861,6 +861,11 @@ fn xobject( } let mut dict = s.dict.clone(); dict.remove(b"Length"); + if new_data.is_some() { + // As for page content streams: the source text of an added item (Edit ▸ Add + // content) would keep what the rewrite removed. + dict.remove(b"PCAdded"); + } let mut cleaned_res = res.clone(); // Install rewritten nested forms before finding live property names: a new form may // still inherit its caller's resources, and its new Do name must resolve here. diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 2d0268d525..3c170aba20 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -3871,3 +3871,29 @@ fn a_reply_on_another_page_to_a_removed_popup_goes_too() { assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived", String::from_utf8_lossy(gone)); } } + +#[test] +fn a_rewritten_form_xobject_drops_the_source_text_of_added_content() { + // A form drawing invisible text whose dictionary repeats it in /PCAdded. + let form = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /PCMark /Added /PCAdded << /Kind /Text /Text (LEAK_FORM_SOURCE) >>", + b"BT /F1 10 Tf 3 Tr 10 100 Td (HIDDEN) Tj ET BT /F1 10 Tf 10 200 Td (Shown) Tj ET", + ); + let mut doc = one_page(b"/X Do", "/XObject << /X 7 0 R >>", vec![form]); + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!bytes.windows(16).any(|w| w == b"LEAK_FORM_SOURCE"), "the form's added-content source text survived"); +} + +#[test] +fn a_redacted_form_xobject_drops_the_source_text_of_added_content() { + let form = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /PCMark /Added /PCAdded << /Kind /Text /Text (AB1234CD) >>", + b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET", + ); + let mut doc = one_page(b"/X Do", "/XObject << /X 7 0 R >>", vec![form]); + mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], ""); + apply(&mut doc, None).unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!bytes.windows(4).any(|w| w == b"1234"), "redacted text left in the saved file"); +} From 359ed56ffa9367abeb5d1efd785aff52d391e334 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 23:34:18 -0500 Subject: [PATCH 05/16] fix(redact): structure titles go with the descriptions of removed content (#5866) Redaction and hidden-content removal cleared /Alt, /ActualText and /E from the structure element owning removed marked content, but kept its /T title, which producers often fill with the element's text. Ancestor cleanup and in-place description detection shared a constant that also omitted /T, while annotation removal had its own list that included it. One DESCRIPTIONS policy (Alt, ActualText, T, E) now serves the owning element, its ancestors, in-place detection and annotation removal. --- crates/redact/src/tags.rs | 12 ++--- crates/redact/src/tests/descriptions.rs | 58 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index 961f016e94..648c0c29e5 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -2,7 +2,7 @@ //! //! Marked content (MCIDs) that applying redactions changed is found by comparing each MCID's //! operators before and after. Elements owning such content lose their alternate text, actual -//! text and expansion (which may spell out the removed words); +//! text, title and expansion (which may spell out the removed words), as do their ancestors; //! references to marked content that is now empty are dropped from the element. use std::collections::{HashMap, HashSet}; @@ -281,7 +281,7 @@ pub(crate) fn clean_within( } doc.update_dict(r, |e| { if sensitive { - for key in [&b"Alt"[..], b"ActualText", b"E"] { + for key in DESCRIPTIONS { e.remove(key); } } @@ -321,8 +321,10 @@ impl Above { } } -/// The keys of an element that describe its content. -const DESCRIPTIONS: [&[u8]; 3] = [b"Alt", b"ActualText", b"E"]; +/// The keys of an element that describe its content, and so may repeat what was removed: one +/// policy for the element that owned it, the elements above it and elements written in place. +/// The title (`/T`) is included, as producers often fill it with the element's text. +const DESCRIPTIONS: [&[u8]; 4] = [b"Alt", b"ActualText", b"T", b"E"]; /// Each element's parents as the walk found them: through `/K`, which `/P` may not agree with. struct Parents(HashMap>); @@ -539,7 +541,7 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet // An element that lost an annotation keeps no text describing it, even with other // kids left: a link's alternate text often repeats the URL, an annotation's its // comment. Elements above it may describe it too. - for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] { + for key in DESCRIPTIONS { e.remove(key); } })?; diff --git a/crates/redact/src/tests/descriptions.rs b/crates/redact/src/tests/descriptions.rs index df6b8555dc..0495dd1f70 100644 --- a/crates/redact/src/tests/descriptions.rs +++ b/crates/redact/src/tests/descriptions.rs @@ -62,3 +62,61 @@ fn a_link_element_that_keeps_its_text_loses_the_description_of_its_removed_annot let link = doc.get(ObjRef::new(11, 0)).as_dict().cloned().unwrap(); assert_eq!(link.get(b"K"), Some(&Object::Array(vec![Object::Int(0)])), "the element keeps its text"); } + +/// [`sectioned`], with titles that repeat the secret on the paragraph that owns it, on its section +/// and on the document element above both. +fn titled() -> Document { + let mut doc = sectioned("11 0 R"); + let title = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec())); + for (n, t) in [(8, "LEAK_DOC_TITLE"), (9, "LEAK_OWN_TITLE"), (11, "LEAK_SECT_TITLE"), (10, "Public title")] { + doc.update_dict(ObjRef::new(n, 0), |d| d.set(b"T".to_vec(), title(t))).unwrap(); + } + doc +} + +#[test] +fn titles_repeating_redacted_content_go_from_the_owner_and_its_ancestors() { + let mut doc = titled(); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + assert!(!full_save_holds(&doc, b"LEAK_"), "a structure title repeating the redacted word survived"); + let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap(); + assert!(public.contains(b"T"), "an element beside it keeps its own title"); +} + +#[test] +fn titles_repeating_hidden_text_go_from_the_owner_and_its_ancestors() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = titled(); + let hidden = + b"/P <> BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC"; + doc.set(ObjRef::new(4, 0), Object::Stream(Stream::from_raw(Dict::new(), hidden.to_vec()))); + remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap(); + assert!(!full_save_holds(&doc, b"SECRET"), "the hidden text survived"); + assert!(!full_save_holds(&doc, b"LEAK_"), "a structure title repeating the hidden text survived"); + let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap(); + assert!(public.contains(b"T"), "an element beside it keeps its own title"); +} + +#[test] +fn an_ancestor_title_describing_a_removed_annotation_goes() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] /StructParents 0 >>" + .to_vec(), + stream("", b"/Link << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"), + FONT.replace("95 0 R", "12 0 R").into_bytes(), + b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://example.org/) >> >>".to_vec(), + b"null".to_vec(), + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [11 0 R] 1 11 0 R] >> /ParentTreeNextKey 2 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K 10 0 R >>".to_vec(), + // Only a title above the link element repeats the address. + b"<< /Type /StructElem /S /P /P 9 0 R /T (LEAK_PARENT_TITLE) /K 11 0 R >>".to_vec(), + b"<< /Type /StructElem /S /Link /P 10 0 R /Pg 3 0 R /K [0 << /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + widths(), + ]); + remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap(); + assert!(!full_save_holds(&doc, b"LEAK_"), "an ancestor's title describing the removed link survived"); +} From 97260514e63102110c974705e6ee64847a8bd918 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 23:36:51 -0500 Subject: [PATCH 06/16] fix(create): write a new document's page tree once (#5865) Create PDF added each page to the page tree through Document::update_dict, which copies the tree's dictionary and its whole /Kids array, so a text of many form feeds took quadratic time: 40,000 pages took 108 s in a debug build. Pages are now collected and /Kids and /Count are written once, for blank, image and text documents. --- crates/create/src/lib.rs | 75 +++++++++++++++++++++++++------------- crates/create/src/tests.rs | 21 +++++++++++ 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 4c117019d7..6346e85120 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -23,29 +23,48 @@ pub const LETTER: (f64, f64) = (612.0, 792.0); /// The largest page side PDF allows (ISO 32000-2 Annex C: 14 400 units). const MAX_SIDE: f64 = 14_400.0; -fn add_page(doc: &mut Document, w: f64, h: f64, resources: Dict, content: Option>) -> Result { - // `Document::new_empty` always has a page tree. - let pages = doc - .root() - .and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))) - .ok_or_else(|| CreateError::Invalid("the new document has no page tree".into()))?; - let mut page = Dict::new(); - page.set(b"Type".to_vec(), Object::name("Page")); - page.set(b"Parent".to_vec(), Object::Ref(pages)); - page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), Object::Real(w), Object::Real(h)])); - page.set(b"Resources".to_vec(), Object::Dict(resources)); - if let Some(c) = content { - let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &c))); - page.set(b"Contents".to_vec(), Object::Ref(s)); +/// The page tree of a document being made, with the pages added to it so far: its `/Kids` are +/// written once, by [`Pages::finish`]. Adding each page to the tree in the document would copy every +/// page before it, which a text of a million form feeds makes quadratic. +struct Pages { + tree: ObjRef, + kids: Vec, +} + +impl Pages { + /// The page tree of `doc`, with the pages it has. + fn of(doc: &Document) -> Result { + // `Document::new_empty` always has a page tree. + let tree = doc + .root() + .and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))) + .ok_or_else(|| CreateError::Invalid("the new document has no page tree".into()))?; + let kids = doc.get(tree).as_dict().and_then(|d| d.get(b"Kids")).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + Ok(Self { tree, kids }) + } + + fn add(&mut self, doc: &mut Document, w: f64, h: f64, resources: Dict, content: Option>) { + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(self.tree)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), Object::Real(w), Object::Real(h)])); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + if let Some(c) = content { + let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &c))); + page.set(b"Contents".to_vec(), Object::Ref(s)); + } + self.kids.push(Object::Ref(doc.add(page))); + } + + /// Writes the pages added into the tree. + fn finish(self, doc: &mut Document) -> Result<(), CreateError> { + let count = i64::try_from(self.kids.len()).map_err(|_| CreateError::Invalid("too many pages".into()))?; + doc.update_dict(self.tree, |d| { + d.set(b"Count".to_vec(), Object::Int(count)); + d.set(b"Kids".to_vec(), Object::Array(self.kids)); + }) + .map_err(|e| CreateError::Invalid(e.to_string())) } - let r = doc.add(page); - let _ = doc.update_dict(pages, |d| { - let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); - kids.push(Object::Ref(r)); - d.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); - d.set(b"Kids".to_vec(), Object::Array(kids)); - }); - Ok(r) } fn set_title(doc: &mut Document, title: &str) { @@ -65,9 +84,11 @@ pub fn blank(width: f64, height: f64, pages: usize) -> Result)], resolution: Ima return Err(CreateError::Invalid("no images".into())); } let mut doc = Document::new_empty(); + let mut tree = Pages::of(&doc)?; let mut profiles = Vec::new(); for img in images.iter().map(|(name, bytes)| embed(name, bytes, usize::MAX)).collect::, _>>()?.into_iter().flatten() { let dpi = match resolution { @@ -692,8 +714,9 @@ pub fn from_images_with_resolution(images: &[(String, Vec)], resolution: Ima let mut res = Dict::new(); res.set(b"XObject".to_vec(), Object::Dict(xobj)); let content = format!("q {w:.3} 0 0 {h:.3} 0 0 cm /Im0 Do Q\n").into_bytes(); - add_page(&mut doc, w, h, res, Some(content))?; + tree.add(&mut doc, w, h, res, Some(content)); } + tree.finish(&mut doc)?; if let Some((name, _)) = images.first() { set_title(&mut doc, name.rsplit_once('.').map_or(name.as_str(), |(s, _)| s)); } @@ -787,6 +810,7 @@ fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); let fr = doc.add(font); + let mut tree = Pages::of(&doc)?; let mut page_lines: Vec> = vec![Vec::new()]; for l in lines.iter().take(lines.len().saturating_sub(1)) { if l == "\u{c}" || page_lines.last().is_some_and(|p| p.len() >= per_page) { @@ -810,8 +834,9 @@ fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result fonts.set(b"F1".to_vec(), Object::Ref(fr)); let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); - add_page(&mut doc, w, h, res, Some(c))?; + tree.add(&mut doc, w, h, res, Some(c)); } + tree.finish(&mut doc)?; set_title(&mut doc, title); Ok(doc) } diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index a7a5611ffc..9e620c19c3 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -124,6 +124,27 @@ fn text_is_wrapped_and_paginated() { assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page"); } +#[test] +fn text_of_many_form_feeds_makes_its_pages_in_linear_time() { + // Each form feed starts a page: adding each to the page tree copied all the pages before it, + // which made forty thousand of them take over a minute in a debug build, against about two + // seconds when the tree is written once. + let text = "x\u{c}".repeat(40_000); + let start = std::time::Instant::now(); + let doc = from_text("many", &text, LETTER, 11.0).unwrap(); + let took = start.elapsed(); + let tree = doc.get(doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap()); + let tree = tree.as_dict().unwrap(); + assert_eq!(tree.get(b"Kids").and_then(|k| k.as_array()).map(|a| a.len()), Some(40_001)); + assert_eq!(tree.int(b"Count"), Some(40_001)); + assert!(took < std::time::Duration::from_secs(30), "{took:?}"); + // Blank documents and pages made from images keep their pages, in order. + let doc = reopen(&blank(200.0, 300.0, 3).unwrap()); + assert_eq!(pages(&doc).len(), 3); + let doc = reopen(&from_images(&[("a.png".into(), png_bytes(false)), ("b.png".into(), png_bytes(true))]).unwrap()); + assert_eq!(pages(&doc).len(), 2); +} + #[test] fn text_keeps_leading_spaces_and_tabs() { let doc = reopen(&from_text("t", " indented\n\ttabbed\nplain", LETTER, 11.0).unwrap()); From 49d5997e1880932d8b8cd9da750d899d6952b8c4 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Tue, 3 Nov 2026 12:38:55 +0800 Subject: [PATCH 07/16] fix(a11y): splice named and multi-stream marked content in mark_tag_artifact (#5263) Share the stream-splicing path between mark_decorative and mark_tag_artifact so mark_tag_artifact resolves named property lists in /Resources /Properties, resolves indirect /MCR references in /K, leaves untouched and shared content streams intact, and validates the tag parent before mutating /Contents or /ParentTree. --- crates/a11y/src/alt.rs | 170 ++++++++++++++---------------- crates/a11y/src/tests.rs | 70 ++++++++++++ crates/a11y/tests/cross_stream.rs | 22 ++-- 3 files changed, 162 insertions(+), 100 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 720b8a6a1e..fde1048e47 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -125,9 +125,9 @@ pub(crate) fn elements(doc: &Document) -> Vec { } /// The marked-content ids an element owns directly. -fn mcids(d: &Dict) -> Vec { +fn mcids(doc: &Document, d: &Dict) -> Vec { let mut out = Vec::new(); - let mut push = |o: &Object| match o { + let mut push = |o: &Object| match &*doc.resolve(o) { Object::Int(n) => out.push(*n), Object::Dict(m) if m.name(b"Type") == Some(b"MCR") => { if let Some(n) = m.get(b"MCID").and_then(Object::as_int) { @@ -323,7 +323,7 @@ pub fn figures(doc: &Document) -> Vec
{ let mut wanted: HashMap> = HashMap::new(); for (e, page) in &figures { if let Some(p) = page { - wanted.entry(*p).or_default().extend(mcids(&e.dict)); + wanted.entry(*p).or_default().extend(mcids(doc, &e.dict)); } } let bounds: HashMap> = @@ -333,7 +333,7 @@ pub fn figures(doc: &Document) -> Vec
{ .map(|(e, page)| { let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())); let on_page = page.and_then(|p| bounds.get(&p)); - let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b)))); + let bbox = on_page.and_then(|b| mcids(doc, &e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b)))); Figure { obj: e.object, page, alt, bbox } }) .collect() @@ -363,52 +363,73 @@ pub fn set_alt(doc: &mut Document, obj: ObjRef, alt: Option<&str>) -> Result<(), } /// The page's content entries: one stream, or the elements of a contents array. -fn content_entries(doc: &Document, page: &Dict) -> Option> { - let c = page.get(b"Contents")?; - Some(match &*doc.resolve(c) { +fn content_entries(doc: &Document, page: &Dict) -> Vec { + let Some(c) = page.get(b"Contents") else { return Vec::new() }; + match &*doc.resolve(c) { Object::Array(a) => a.clone(), Object::Stream(_) => vec![c.clone()], - _ => return None, - }) + _ => Vec::new(), + } } -fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet) -> bool { - op.op == b"BDC" && bdc_mcid(doc, props, op).is_some_and(|n| ids.contains(&n)) -} +type DecodedStream = (Object, Option<(Stream, Vec)>); -/// Turn the figure's marked-content blocks into artifacts, rewriting only the streams that -/// contain them. Other content streams, and unknown entries on a rewritten stream, stay. -fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: &HashSet) -> Result { - let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) }; - let props = page_properties(doc, &page.dict); - let mut streams: Vec)>> = Vec::with_capacity(entries.len()); +/// Decode the page's content streams within the page decode budget. `None` when a stream is +/// rejected because the budget is exhausted. +fn decode_page_streams(doc: &Document, page: &Dict) -> Option> { + let entries = content_entries(doc, page); + let mut streams = Vec::with_capacity(entries.len()); let mut budget = DecodeBudget::page(); - for entry in &entries { - match &*doc.resolve(entry) { + for entry in entries { + let decoded = match &*doc.resolve(&entry) { Object::Stream(s) => { let data = match budget.decode(s) { Ok(data) => data, - // Content past the budget may hold more of the figure: editing the rest - // would leave that part marked as a figure the tree no longer has. - Err(_) if budget.exhausted() => return Err(AltError::TooLarge), + // Content past the budget may hold more of the tag: editing the rest + // would leave that part marked as a tag the tree no longer has. + Err(_) if budget.exhausted() => return None, Err(_) => Vec::new(), }; - streams.push(Some((s.clone(), data))); + Some((s.clone(), data)) } - _ => streams.push(None), + _ => None, + }; + streams.push((entry, decoded)); + } + Some(streams) +} + +fn marked_id(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet) -> Option { + (op.op == b"BDC").then(|| bdc_mcid(doc, props, op)).flatten().filter(|n| ids.contains(n)) +} + +/// Turn the tag's marked-content blocks into artifacts, rewriting only the streams that +/// contain them. Other content streams, and unknown entries on a rewritten stream, stay. +/// With `require_all`, every id in `ids` must appear on the page before any stream is rewritten. +fn artifact_marked_content( + doc: &mut Document, + page: &pdfcraft_model::Page, + streams: Vec, + ids: &HashSet, + require_all: bool, +) -> Result { + let props = page_properties(doc, &page.dict); + let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes)| bytes.as_slice())).collect(); + let pieces = pdfcraft_content::Pieces::join(&decoded); + let mut found = HashSet::new(); + let mut edits = Vec::new(); + for op in pieces.parse().ops { + if let Some(id) = marked_id(doc, &props, &op, ids) { + found.insert(id); + edits.push((op.span, b"/Artifact BMC".to_vec())); } } - let decoded: Vec<&[u8]> = streams.iter().flatten().map(|(_, bytes)| bytes.as_slice()).collect(); - let pieces = pdfcraft_content::Pieces::join(&decoded); - let edits: Vec<_> = - pieces.parse().ops.into_iter().filter(|op| marked(doc, &props, op, ids)).map(|op| (op.span, b"/Artifact BMC".to_vec())).collect(); - if edits.is_empty() { + if if require_all { found != *ids } else { found.is_empty() } { return Ok(false); } let mut spliced = pieces.splice(edits).into_iter(); - let mut next = Vec::with_capacity(entries.len()); - let mut changed = false; - for (entry, stream) in entries.into_iter().zip(streams) { + let mut next = Vec::with_capacity(streams.len()); + for (entry, stream) in streams { let Some((stream, old)) = stream else { next.push(entry); continue; @@ -421,7 +442,6 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: next.push(entry); continue; } - changed = true; let mut dict = stream.dict; dict.remove(b"Length"); dict.remove(b"Filter"); @@ -431,28 +451,27 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: // appear elsewhere in this array or on another page, where it must stay unchanged. next.push(Object::Ref(doc.add(stream))); } - if changed { - let contents = match next.as_slice() { - [one] => one.clone(), - _ => Object::Array(next), - }; - doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?; - } - Ok(changed) + let contents = match next.as_slice() { + [one] => one.clone(), + _ => Object::Array(next), + }; + doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?; + Ok(true) } /// Decorative figure: its marked content on the page becomes an artifact, and the element /// leaves the structure tree (and the parent tree). pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> { let d = check_figure(doc, obj)?; - let ids: HashSet = mcids(&d).into_iter().collect(); + let ids: HashSet = mcids(doc, &d).into_iter().collect(); let pages = pdfcraft_model::pages(doc); let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page)); - if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) - && artifact_marked_content(doc, page, &ids)? - { + if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) { + let streams = decode_page_streams(doc, &page.dict).ok_or(AltError::TooLarge)?; // The parent tree no longer points those ids at the figure. - if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) { + if artifact_marked_content(doc, page, streams, &ids, false)? + && let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) + { clear_parent_tree(doc, key, obj)?; } } @@ -484,54 +503,23 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact if has_child_structure(doc, &element.dict) { return Err(ArtifactError::HasChildren); } - let ids: HashSet = mcids(&element.dict).into_iter().collect(); + let ids: HashSet = mcids(doc, &element.dict).into_iter().collect(); if ids.is_empty() { return Err(ArtifactError::NoMarkedContent); } let pages = pdfcraft_model::pages(doc); let page_ref = element.page.ok_or(ArtifactError::NoPage)?; let page = pages.iter().find(|page| page.obj == page_ref).ok_or(ArtifactError::NoPage)?; - let mut content = Vec::new(); - if let Some(c) = page.dict.get(b"Contents") { - let mut budget = DecodeBudget::page(); - for data in budget.decode_contents(doc, c) { - match data { - Ok(data) => { - content.extend_from_slice(&data); - content.push(b' '); - } - // The page's /Contents is replaced with what was decoded: content past the - // budget would be dropped from the page. - Err(_) if budget.exhausted() => return Err(ArtifactError::TooLarge), - Err(_) => {} - } - } - } - let mut ops = pdfcraft_content::parse(&content).ops; - let mut found = HashSet::new(); - for op in &mut ops { - let id = (op.op == b"BDC") - .then(|| match op.operands.get(1) { - Some(Object::Dict(mark)) => mark.get(b"MCID").and_then(Object::as_int), - _ => None, - }) - .flatten() - .filter(|id| ids.contains(id)); - if let Some(id) = id { - *op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]); - found.insert(id); - } - } - if found != ids { + let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?; + let replacement = replacement_kids(doc, parent, obj)?; + let streams = decode_page_streams(doc, &page.dict).ok_or(ArtifactError::TooLarge)?; + if !artifact_marked_content(doc, page, streams, &ids, true)? { return Err(ArtifactError::ContentNotFound); } - let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &pdfcraft_content::serialize_ops(&ops)))); - doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), Object::Ref(stream)))?; if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) { clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?; } - let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?; - remove_from_parent(doc, parent, obj)?; + doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?; // Keep the retired object valid if another object holds an out-of-tree reference to it. doc.update_dict(obj, |d| { d.remove(b"P"); @@ -551,20 +539,18 @@ fn has_child_structure(doc: &Document, tag: &Dict) -> bool { tag.get(b"K").is_some_and(|items| child(doc, items)) } -fn remove_from_parent(doc: &mut Document, parent: ObjRef, obj: ObjRef) -> Result<(), ArtifactError> { +fn replacement_kids(doc: &Document, parent: ObjRef, obj: ObjRef) -> Result { let kids = doc.get(parent).as_dict().and_then(|p| p.get(b"K").cloned()).ok_or(ArtifactError::NoParent)?; - let replacement = match kids { + match kids { Object::Array(items) => { if items.iter().filter(|item| item.as_ref() == Some(obj)).count() != 1 { return Err(ArtifactError::AmbiguousParent); } - Object::Array(items.into_iter().filter(|item| item.as_ref() != Some(obj)).collect()) + Ok(Object::Array(items.into_iter().filter(|item| item.as_ref() != Some(obj)).collect())) } - Object::Ref(r) if r == obj => Object::Array(Vec::new()), - _ => return Err(ArtifactError::NoParent), - }; - doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?; - Ok(()) + Object::Ref(r) if r == obj => Ok(Object::Array(Vec::new())), + _ => Err(ArtifactError::NoParent), + } } /// Replace `obj` by null in the page's parent-tree array (`key`), in a flat /Nums tree. diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index a70ffbf385..3024319ec3 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1017,6 +1017,76 @@ fn named_property_lists_are_found_and_marked_decorative_like_inline_ones() { assert!(props.contains(b"P0") && props.contains(b"P1") && props.contains(b"Other"), "resources are left as they were"); } +#[test] +fn mark_tag_artifact_finds_named_property_lists_and_indirect_mcrs() { + use pdfcraft_cos::{Dict, ObjRef, Object}; + let mut doc = named_figures_doc(); + let mcr = doc.add(Object::Dict([(b"Type".to_vec(), Object::name("MCR")), (b"MCID".to_vec(), Object::Int(0))].into_iter().collect::())); + doc.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Ref(mcr))).unwrap(); + assert_eq!(figures(&doc)[0].bbox, Some([10.0, 150.0, 50.0, 170.0]), "indirect MCR is resolved for figure bounds"); + + for (tag, gone) in [(ObjRef::new(7, 0), "/P0"), (ObjRef::new(8, 0), "/P1")] { + mark_tag_artifact(&mut doc, tag).unwrap(); + let pages = pdfcraft_model::pages(&doc); + let c = doc.resolve(pages[0].dict.get(b"Contents").unwrap()); + let Object::Stream(c) = &*c else { panic!("one content stream") }; + let text = String::from_utf8(c.decoded().unwrap()).unwrap(); + assert!(text.contains("/Artifact BMC") && !text.contains(gone), "{text}"); + } + assert_eq!(tags(&doc).into_iter().map(|t| t.object.num).collect::>(), [6]); + let nums = doc.get(ObjRef::new(9, 0)).as_dict().and_then(|d| d.get(b"Nums").cloned()); + assert_eq!(nums, Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Null, Object::Null])]))); + let r = check(&doc, &Options { rules: [Rule::FiguresAltText, Rule::TaggedContent].into(), pages: None }); + assert_eq!(failed(&r), Vec::::new(), "{:?}", r.results); +} + +#[test] +fn mark_tag_artifact_validates_parent_and_all_mcids_before_mutating_document() { + use pdfcraft_cos::{ObjRef, Object}; + + // Missing /P on the tag must fail before touching /Contents or /ParentTree. + let mut no_parent = figures_doc(); + let before_contents = no_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(); + let before_pt = no_parent.get(ObjRef::new(9, 0)).as_ref().clone(); + no_parent + .update_dict(ObjRef::new(7, 0), |d| { + d.remove(b"P"); + }) + .unwrap(); + assert_eq!(mark_tag_artifact(&mut no_parent, ObjRef::new(7, 0)), Err(ArtifactError::NoParent)); + assert_eq!(no_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents); + assert_eq!(*no_parent.get(ObjRef::new(9, 0)), before_pt); + + // Duplicate entry in parent's /K must fail with AmbiguousParent before touching /Contents or /ParentTree. + let mut dup_parent = figures_doc(); + dup_parent + .update_dict(ObjRef::new(6, 0), |d| { + d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0)), Object::Ref(ObjRef::new(7, 0))])); + }) + .unwrap(); + assert_eq!(mark_tag_artifact(&mut dup_parent, ObjRef::new(7, 0)), Err(ArtifactError::AmbiguousParent)); + assert_eq!(dup_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents); + assert_eq!(*dup_parent.get(ObjRef::new(9, 0)), before_pt); + + // Partial MCID match (MCID 0 present, MCID 99 absent) must fail with ContentNotFound in + // mark_tag_artifact without mutating /Contents, while mark_decorative still converts MCID 0. + let mut partial = figures_doc(); + partial.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(99)]))).unwrap(); + assert_eq!(mark_tag_artifact(&mut partial, ObjRef::new(7, 0)), Err(ArtifactError::ContentNotFound)); + assert_eq!(partial.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents); + assert_eq!(*partial.get(ObjRef::new(9, 0)), before_pt); + mark_decorative(&mut partial, ObjRef::new(7, 0)).unwrap(); + assert_ne!(partial.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents); + assert_ne!(*partial.get(ObjRef::new(9, 0)), before_pt); + + // When none of a figure's MCIDs appear on the page, mark_decorative leaves /Contents and /ParentTree untouched. + let mut absent = figures_doc(); + absent.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Int(99))).unwrap(); + mark_decorative(&mut absent, ObjRef::new(7, 0)).unwrap(); + assert_eq!(absent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents); + assert_eq!(*absent.get(ObjRef::new(9, 0)), before_pt); +} + fn ref_(n: u32) -> Option { Some(pdfcraft_cos::ObjRef::new(n, 0)) } diff --git a/crates/a11y/tests/cross_stream.rs b/crates/a11y/tests/cross_stream.rs index ad613a2939..d0a2f48204 100644 --- a/crates/a11y/tests/cross_stream.rs +++ b/crates/a11y/tests/cross_stream.rs @@ -99,14 +99,20 @@ fn shown_strings(ops: &[Op]) -> Vec { #[test] fn mark_decorative_leaves_the_other_stream_untouched() { - let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET"); - let before = doc.get(ObjRef::new(11, 0)).clone(); - mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds"); - assert!(figures(&doc).is_empty()); - assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object"); - let page = doc.get(ObjRef::new(3, 0)); - let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap(); - assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy"); + for use_artifact in [false, true] { + let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET"); + let before = doc.get(ObjRef::new(11, 0)).clone(); + if use_artifact { + mark_tag_artifact(&mut doc, ObjRef::new(8, 0)).expect("mark tag artifact succeeds"); + } else { + mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds"); + } + assert!(figures(&doc).is_empty()); + assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object"); + let page = doc.get(ObjRef::new(3, 0)); + let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap(); + assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy"); + } } /// A and B supply different operands to R's BDC. Reusing R must not let the first From 97a911053374f6c6e8a5fb0567146c63822df6b1 Mon Sep 17 00:00:00 2001 From: SSwanson Date: Mon, 2 Nov 2026 22:32:38 -0700 Subject: [PATCH 08/16] M12: FormCalc prints numbers to at most 15 significant digits (#5819) `num_text` printed every non-integer with twelve fixed decimals. A value with six or more digits before the point then shows the binary noise of the double that holds it: a FormCalc calculate `qty * price` with price 279893.88 gave `279893.880000000005`, and a Sum() of five such amounts gave `1466917.350000000093`. Real invoice forms (USACE ENG Form 93, a dynamic XFA payment estimate) show these in their amount and total fields. Print at most 15 significant digits (the most a double holds for every value) and at most 12 decimals, as before for small values, so sums that should be zero still print 0. Only a fraction loses trailing zeros: past 15 digits before the point no decimals are left and the integer keeps its zeros. Co-authored-by: seanGSISG --- crates/js/src/formcalc.rs | 18 ++++++++++++++++++ crates/js/src/formcalc/value.rs | 14 +++++++++++--- crates/js/src/tests.rs | 14 ++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 3e68ce081d..cfad619938 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -227,4 +227,22 @@ mod tests { assert_eq!(parse_num("12abc"), Some(12.0)); assert_eq!(parse_num("abc"), None); } + + /// A number prints to at most fifteen significant digits, so the binary noise of a value + /// with six digits before the point (279893.88 is 279893.880000000004656… as a double) stays + /// hidden, while small values keep their twelve decimals. + #[test] + fn num_text_prints_at_most_fifteen_significant_digits() { + assert_eq!(num_text(279_893.88), "279893.88"); + assert_eq!(num_text(-279_893.88), "-279893.88"); + assert_eq!(num_text(279_893.88 + 291_774.12 + 304_171.06 + 317_107.44 + 273_970.85), "1466917.35"); + assert_eq!(num_text(1_000_000.0 / 3.0), "333333.333333333"); + assert_eq!(num_text(1.0 / 3.0), "0.333333333333"); + assert_eq!(num_text(0.3 - 0.1 - 0.2), "0", "noise below twelve decimals is zero"); + // Past fifteen digits before the point no decimals are left, and the integer keeps its zeros. + assert_eq!(num_text(1e15 + 0.5), "1000000000000000"); + assert_eq!(num_text(1e20), "100000000000000000000"); + assert_eq!(num_text(-1e20), "-100000000000000000000"); + assert_eq!(num_text(f64::MAX).len(), 309); + } } diff --git a/crates/js/src/formcalc/value.rs b/crates/js/src/formcalc/value.rs index 16705e67ec..5ea9d4c089 100644 --- a/crates/js/src/formcalc/value.rs +++ b/crates/js/src/formcalc/value.rs @@ -21,7 +21,11 @@ pub(super) enum Val { Sink, } -/// A number as FormCalc prints it: integers without a fraction, otherwise up to 15 digits. +/// A number as FormCalc prints it: integers without a fraction, otherwise to at most 12 +/// decimals and 15 significant digits, without trailing zeros. 15 digits is the most a double +/// holds for every value, so a value typed with up to 15 digits prints back as typed +/// (`279893.88 * 1` is `279893.88`, not `279893.880000000005`), and the binary noise past +/// them never shows. The 12 decimals hide noise left by sums that should be zero. pub(super) fn num_text(n: f64) -> String { if !n.is_finite() { return if n.is_nan() { @@ -35,8 +39,12 @@ pub(super) fn num_text(n: f64) -> String { if n.fract() == 0.0 && n.abs() < 1e15 { return format!("{}", n as i64); } - let s = format!("{n:.12}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); + // Digits before the point (at most 309 for a finite double); 1 for |n| < 1. + let int_digits = format!("{:.0}", n.abs().trunc()).len(); + let decimals = 15usize.saturating_sub(int_digits).min(12); + let s = format!("{n:.decimals$}"); + // Only a fraction loses its trailing zeros: with no decimals left they belong to the integer. + let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.') } else { s.as_str() }; if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() } } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 2d3877efe3..b61649b729 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1026,6 +1026,20 @@ mod formcalc { assert_eq!(calc(r#"Format("9.9", -1.5)"#), "-1.5"); } + /// A calculated amount shows the number it is, not the binary noise past a double's 15 + /// significant digits: 279893.88 is stored as 279893.880000000004656…, which twelve fixed + /// decimals printed as `279893.880000000005`. Small values keep their twelve decimals. + #[test] + fn calculated_numbers_print_at_most_fifteen_significant_digits() { + assert_eq!(calc("1 * 279893.88"), "279893.88"); + assert_eq!(calc("1 * 291774.12"), "291774.12"); + assert_eq!(calc("279893.88 + 291774.12 + 304171.06 + 317107.44 + 273970.85"), "1466917.35"); + assert_eq!(calc("1000000 / 3"), "333333.333333333"); + assert_eq!(calc("1000 / 3"), "333.333333333333"); + assert_eq!(calc("1 / 3"), "0.333333333333"); + assert_eq!(calc("0.1 + 0.2"), "0.3"); + } + /// Assigning one long value to many fields at once stops at the run's byte budget and is /// charged per copy, and a reset list names each field once. #[test] From 872a775010875701935f08b52bfd7fb3ec779ce1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:32:44 +0100 Subject: [PATCH 09/16] Expand header/footer tokens preceded by extra '<' characters (#5766) Found while testing crates/edit/src/marks.rs:247-262 (expand) Co-authored-by: Claude Haiku 5.5 --- crates/edit/src/marks.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs index d936955184..344c76250f 100644 --- a/crates/edit/src/marks.rs +++ b/crates/edit/src/marks.rs @@ -286,7 +286,12 @@ pub fn expand(template: &str, page_number: u32, count: usize, bates: u64, cx: &C let digits = parts.get(1).and_then(|d| d.parse::().ok()).unwrap_or(6).clamp(1, 15); format!("{}{:0digits$}{}", parts.get(3).unwrap_or(&""), bates, parts.get(4).unwrap_or(&"")) } - _ => format!("<<{token}>>"), + _ => { + // Not a token: emit one `<` and retry, so a later token is still found. + out.push('<'); + rest = &rest[start + 1..]; + continue; + } }; out.push_str(&value); rest = &after[end + 2..]; @@ -302,3 +307,16 @@ pub(crate) fn bates_start(template: &str) -> Option { let t = &t[..t.find(">>")?]; t.split('#').nth(2).and_then(|s| s.parse().ok()).or(Some(1)) } + +#[cfg(test)] +mod expand_tests { + use super::*; + + #[test] + fn expand_token_after_extra_angle_brackets() { + let cx = Context::default(); + assert_eq!(expand("<<<1>>>", 3, 9, 1, &cx), "<3>"); + assert_eq!(expand("<< <<1>>", 3, 9, 1, &cx), "<< 3"); + assert_eq!(expand("<> <<1>>", 3, 9, 1, &cx), "<> 3"); + } +} From 023af5bdb2336b024695e2881219a7e4a21dd050 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:32:50 +0100 Subject: [PATCH 10/16] M7: Arrange keeps a top-level inline image a unit of its own (#3146) (#5823) * M7: Arrange keeps a top-level inline image a unit of its own (#3146) The parser emits a whole inline image (BI ... ID ... EI) as one BI op, but content_units scanned on from it for an EI op that never comes, so the unit ran to the end of the stream. SendBackward on an image after a top-level inline image then acted like SendToBack, and BringForward/BringToFront on an image in the swallowed region could do nothing. A BI op is now a unit of its own, like Do and sh. * M7: pin Arrange around inline images at every position (#3146) Check the exact content units and the paint order after each move, not string positions: several top-level inline images (at the start, side by side and at the end) are one unit each, BringForward and SendBackward step past one inline image at a time, BringToFront on the front image is refused without touching the page, and SendToBack and BringToFront go past inline images that start or end the stream. A guard checks that an inline image inside q/Q or marked content stays in that unit. Document content_units. * M7: Arrange tests check each image's transform through q/Q (#3146) --- crates/edit/src/images.rs | 10 ++- crates/edit/src/tests.rs | 127 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 73aa116ff3..f4680bcc5c 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -584,6 +584,10 @@ fn sequence_end(ops: &[Op], i: usize, end: usize, opens: &[&str], close: &str) - end } +/// The stacking units of `ops[scan_start..scan_end]` that Arrange moves past one another, in +/// paint order: a `q … Q`, `BT … ET` or marked-content sequence up to its matching close (or the +/// end of the scan), a single `Do`, `sh` or inline image, or a run of other operators that ends +/// at a path-painting operator or before the next unit. Units that paint nothing are left out. pub(crate) fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec> { let mut units = Vec::new(); let mut i = scan_start; @@ -598,9 +602,9 @@ pub(crate) fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> V sequence_end(ops, i, scan_end, &["q"], "Q") } else if op.is("BT") { sequence_end(ops, i, scan_end, &[], "ET") - } else if op.is("BI") { - sequence_end(ops, i, scan_end, &[], "EI") - } else if op.is("Do") || op.is("sh") { + } else if op.is("BI") || op.is("Do") || op.is("sh") { + // The parser emits a whole inline image (`BI` … `ID` … `EI`) as one `BI` op, so it is a + // unit of its own (#3146). i.saturating_add(1) } else { let mut j = i; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index a6eb76b62b..e78da842b8 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -9813,6 +9813,133 @@ fn content_units_does_not_swallow_subsequent_units_on_standalone_gs() { assert_eq!(units.len(), 2); } +/// A 1 × 1 grey inline image whose one sample is `data`. +fn inline_image(data: char) -> String { + format!("BI /W 1 /H 1 /CS /G /BPC 8 ID {data} EI") +} + +/// What page 0 paints, in order: `BI x` for an inline image with sample `x`, `Do x,y` for an +/// XObject placed by a `cm` translating to x,y, and `Tj` for text. A `cm` lasts until its `Q`, so an +/// XObject moved out of its `q`/`Q` wrapper shows as a bare `Do `. +fn painted(doc: &Document) -> Vec { + let mut at = vec![String::new()]; + pdfcraft_content::parse(&page_content_bytes(doc, 0)) + .ops + .iter() + .filter_map(|op| match op.op.as_slice() { + b"q" => { + at.push(at.last().unwrap().clone()); + None + } + b"Q" => { + at.pop(); + None + } + b"cm" => { + let m = op.nums::<6>().unwrap(); + *at.last_mut().unwrap() = format!("{},{}", m[4], m[5]); + None + } + b"Do" => Some(format!("Do {}", at.last().unwrap())), + b"BI" => Some(format!("BI {}", String::from_utf8_lossy(&op.inline.as_ref().unwrap().1))), + b"Tj" => Some("Tj".into()), + _ => None, + }) + .collect() +} + +/// The index in [`images::page_images`] of the image whose box starts at `x`,`y`. +fn image_at(doc: &Document, x: f64, y: f64) -> usize { + images::page_images(doc, 0).unwrap().iter().position(|i| (i.rect[0] - x).abs() < 1e-6 && (i.rect[1] - y).abs() < 1e-6).unwrap() +} + +#[test] +fn content_units_keep_each_top_level_inline_image_to_itself() { + // The parser emits a whole inline image as one `BI` op; its unit must not run on to the end + // (#3146). Inline images at the start, side by side and at the end are one unit each. + let content = format!( + "{} q 200 0 0 100 100 100 cm /Im0 Do Q {} {} q 10 0 0 10 300 300 cm /Im0 Do Q {}", + inline_image('a'), + inline_image('b'), + inline_image('c'), + inline_image('d') + ); + let ops = pdfcraft_content::parse(content.as_bytes()).ops; + let units = images::content_units(&ops, 0, ops.len()); + assert_eq!(units, [0..1, 1..5, 5..6, 6..7, 7..11, 11..12]); +} + +#[test] +fn content_units_keep_a_nested_inline_image_inside_its_enclosing_unit() { + // A `BI` inside `q`/`Q` or a marked-content sequence stays part of that sequence's unit. + let content = format!("q {} Q /Span << /MCID 0 >> BDC {} EMC {}", inline_image('a'), inline_image('b'), inline_image('c')); + let ops = pdfcraft_content::parse(content.as_bytes()).ops; + let units = images::content_units(&ops, 0, ops.len()); + assert_eq!(units, [0..3, 3..6, 6..7]); +} + +#[test] +fn send_backward_after_a_top_level_inline_image_moves_one_step() { + // Before #3146 the inline image's unit swallowed everything after it, so SendBackward on a later + // image acted like SendToBack: it must step behind the text only. + let content = format!( + "{} q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET q 150 0 0 150 200 200 cm /Im0 Do Q", + inline_image('x') + ); + let mut doc = image_page_with(&content); + let front = image_at(&doc, 200.0, 200.0); + arrange(&mut doc, 0, front, images::ArrangeOrder::SendBackward).unwrap(); + let doc = reopen(&doc); + assert_eq!(painted(&doc), ["BI x", "Do 100,100", "Do 200,200", "Tj"]); +} + +#[test] +fn arrange_steps_an_image_past_one_inline_image_at_a_time() { + // Before #3146 BringForward on B, inside the first inline image's unit, put it in front of + // everything. One step forward is past the next inline image only, and one step back again + // is behind that inline image only. + let content = format!( + "q 100 0 0 100 100 100 cm /Im0 Do Q {} q 150 0 0 150 200 200 cm /Im0 Do Q {} BT /F1 12 Tf 72 700 Td (Text) Tj ET q 50 0 0 50 300 300 cm /Im0 Do Q", + inline_image('a'), + inline_image('b') + ); + let mut doc = image_page_with(&content); + let b = image_at(&doc, 200.0, 200.0); + arrange(&mut doc, 0, b, images::ArrangeOrder::BringForward).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(painted(&doc), ["Do 100,100", "BI a", "BI b", "Do 200,200", "Tj", "Do 300,300"]); + let b = image_at(&doc, 200.0, 200.0); + arrange(&mut doc, 0, b, images::ArrangeOrder::SendBackward).unwrap(); + let doc = reopen(&doc); + assert_eq!(painted(&doc), ["Do 100,100", "BI a", "Do 200,200", "BI b", "Tj", "Do 300,300"]); +} + +#[test] +fn arrange_around_inline_images_at_the_ends_of_the_stream() { + // Before #3146 BringToFront on the last image after an inline image "succeeded" without moving + // it. It is already in front, so it is refused and nothing changes. + let content = + format!("{} q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Text) Tj ET q 150 0 0 150 200 200 cm /Im0 Do Q", inline_image('a')); + let mut doc = image_page_with(&content); + let before = page_content_bytes(&doc, 0); + let front = image_at(&doc, 200.0, 200.0); + assert!(arrange(&mut doc, 0, front, images::ArrangeOrder::BringToFront).is_err()); + assert!(arrange(&mut doc, 0, front, images::ArrangeOrder::BringForward).is_err()); + assert_eq!(page_content_bytes(&doc, 0), before); + // Send to back goes behind the inline image that starts the stream. + arrange(&mut doc, 0, front, images::ArrangeOrder::SendToBack).unwrap(); + let doc = reopen(&doc); + assert_eq!(painted(&doc), ["Do 200,200", "BI a", "Do 100,100", "Tj"]); + + // Bring to front goes past the inline image that ends the stream. + let content = format!("q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Text) Tj ET {}", inline_image('z')); + let mut doc = image_page_with(&content); + let back = image_at(&doc, 100.0, 100.0); + arrange(&mut doc, 0, back, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + assert_eq!(painted(&doc), ["Tj", "BI z", "Do 100,100"]); +} + #[test] fn different_inline_soft_masks_have_distinct_identities() { let doc = Document::new_empty(); From cb405f207e33ee4437245fcfa94166409c75a0f5 Mon Sep 17 00:00:00 2001 From: quincy-jc Date: Tue, 3 Nov 2026 16:32:58 +1100 Subject: [PATCH 11/16] fix(optimize): make pictures under a stencil mask smaller (#5847) * fix(optimize): make pictures under a stencil mask smaller An image with any /Mask was skipped by the colour and grey settings. That is right for a colour-key mask, which names sample values that resampling and JPEG would change. A stencil mask is an image of its own, with its own resolution (ISO 32000-2 8.9.6.3), so the picture can change under it and the mask stays as it is. Some exporters put one on every picture, even where it hides nothing, so whole drawing sets of pictures were left at full size. A mask that can't be read as a stencil, such as a reference to nothing, still leaves its picture alone. Co-Authored-By: Claude Opus 5.5 * fix(optimize): only a mask that says /ImageMask true counts as a stencil A stream /Mask without /ImageMask true let its picture be replaced, although a mask that isn't a stencil is meant to leave its picture alone. It now does. The stencil test also checks that the hole still shows what's behind it. The first pixel all round is left out, because the coarser picture blends into it a little (at most 26 of 255 in this test). Co-Authored-By: Claude Opus 5.5 * test(optimize): draw a coloured page behind the stencil test's picture On a white page, a hole painted white looks the same as an open hole, so the check on the hole couldn't tell them apart. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: quincy-jc <184324679+quincy-jc@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/optimize/README.md | 5 +- crates/optimize/src/images.rs | 10 +++- crates/optimize/tests/optimize.rs | 99 +++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 4 files changed, 113 insertions(+), 5 deletions(-) diff --git a/crates/optimize/README.md b/crates/optimize/README.md index d7f8869c12..2a6c664e6a 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -28,9 +28,10 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going If a use can't be measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a 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. + stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. A + stencil `/Mask` has its own resolution, so the picture changes under it and it stays as it is. An image is replaced only if the result is smaller. Left alone by these colour and grey - settings: CMYK and other colour spaces, image masks, decode arrays, more than 8 bits, JPEG 2000, + settings: CMYK and other colour spaces, image masks, colour-key masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT (black-and-white images, CCITT and JBIG2 among them, are below). 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 diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 92e68582c2..efff394a6f 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -717,6 +717,11 @@ fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Co } } +/// Whether a `/Mask` value is a stencil mask: an image that says `/ImageMask true`. +fn is_stencil(doc: &Document, mask: &Object) -> bool { + matches!(&*doc.resolve(mask), Object::Stream(s) if matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true)))) +} + /// One image: resample and/or recompress per `settings`; `ppi` is its effective resolution. /// Returns the new image and its new soft mask, if the result is smaller. fn process( @@ -728,8 +733,11 @@ fn process( budget: &mut ImageBudget, ) -> Option<(Stream, Option<(ObjRef, Stream)>)> { let d = &s.dict; + // A colour-key mask names sample values, which resampling and JPEG would change. A stencil mask + // is an image of its own, with its own resolution (ISO 32000-2 §8.9.6.3): the picture can change + // under it and its edges stay as they are. if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n)) - || d.contains(b"Mask") + || d.get(b"Mask").is_some_and(|m| !is_stencil(doc, m)) || d.name(b"ImageMask").is_some() || matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) { diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 96f9c91a72..ad710f3097 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3788,3 +3788,102 @@ fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margin assert_eq!(render(&saved), before, "local halftone {side}x{side}"); } } + +/// A one-bit stencil of `w` × `h`, solid but for a hole in its middle. +fn stencil(doc: &mut Document, w: u32, h: u32) -> ObjRef { + let row = (w as usize).div_ceil(8); + let mut bits = vec![0u8; row * h as usize]; + for y in (h as usize / 3)..(2 * h as usize / 3) { + for x in (w as usize / 3)..(2 * w as usize / 3) { + bits[y * row + x / 8] |= 0x80 >> (x % 8); + } + } + 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"ImageMask".to_vec(), Object::Bool(true)); + d.set(b"Width".to_vec(), Object::Int(i64::from(w))); + d.set(b"Height".to_vec(), Object::Int(i64::from(h))); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + doc.add(Object::Stream(Stream::flate(d, &bits))) +} + +/// A picture with a stencil mask is made smaller like any other: the mask has a resolution of its +/// own, so it stays exactly as it was and the hole it cuts stays where it was. +#[test] +fn a_picture_with_a_stencil_mask_is_downsampled_under_its_mask() { + let mut doc = Document::new_empty(); + let mask = stencil(&mut doc, 1200, 1200); + let img = image(&mut doc, 1200, 1200, 3, true, None); + let mut s = stream(&doc, img); + s.dict.set(b"Mask".to_vec(), Object::Ref(mask)); + doc.set(img, Object::Stream(s)); + let mask_before = stream(&doc, mask); + // 1200 pixels over two inches: 600 ppi. + page(&mut doc, &[("Im0", img)], "0 0 1 rg 0 0 612 792 re f q 144 0 0 144 100 100 cm /Im0 Do Q"); + let before = render(&doc); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 1, "{report:?}"); + let after = stream(&doc, img); + assert_eq!(after.dict.int(b"Width"), Some(300), "150 ppi"); + assert_eq!(after.dict.reference(b"Mask"), Some(mask), "the mask stays on it"); + assert_eq!(stream(&doc, mask), mask_before, "and doesn't change"); + let rendered = render(&doc); + assert_eq!(rendered.len(), before.len()); + let changed = before.iter().zip(&rendered).filter(|(a, b)| a.abs_diff(**b) > 96).count(); + assert!(changed * 200 < before.len(), "the masked picture looks the same: {changed} of {} samples far off", before.len()); + // The hole, the middle third of the picture on the 612 by 792 page, still shows what's behind it. + // Its first pixel all round is left out: the coarser picture blends into that a little. + for y in 597..643 { + for x in 149..195 { + let i = (y * 612 + x) * 4; + assert_eq!(rendered[i..i + 4], before[i..i + 4], "hole pixel ({x}, {y})"); + } + } +} + +/// A colour-key mask names sample values: resampling or JPEG would make holes where there were none +/// and fill those there were, so the picture stays as it is, however the array is written. A mask +/// that can't be read as a stencil is no reason to change the picture either. +#[test] +fn a_picture_whose_mask_is_not_a_stencil_is_left_alone() { + type Mask = fn(&mut Document) -> Object; + fn key() -> Object { + Object::Array([250, 255, 250, 255, 250, 255].map(Object::Int).to_vec()) + } + fn not_a_stencil(doc: &mut Document, change: fn(&mut Dict)) -> Object { + let mask = stencil(doc, 1200, 1200); + let mut s = stream(doc, mask); + change(&mut s.dict); + doc.set(mask, Object::Stream(s)); + Object::Ref(mask) + } + let masks: [(&str, Mask); 5] = [ + ("direct array", |_| key()), + ("array by reference", |doc| Object::Ref(doc.add(key()))), + ("nothing there", |doc| Object::Ref(doc.add(Object::Null))), + ("an image that says it isn't a stencil", |doc| { + not_a_stencil(doc, |d| { + d.set(b"ImageMask".to_vec(), Object::Bool(false)); + }) + }), + ("an image that doesn't say it is one", |doc| { + not_a_stencil(doc, |d| { + d.remove(b"ImageMask"); + }) + }), + ]; + for (what, mask) in masks { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, true, None); + let mut s = stream(&doc, img); + let mask = mask(&mut doc); + s.dict.set(b"Mask".to_vec(), mask); + doc.set(img, Object::Stream(s)); + let before = stream(&doc, img); + page(&mut doc, &[("Im0", img)], "q 144 0 0 144 100 100 cm /Im0 Do Q"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled + report.images_recompressed, 0, "{what}: {report:?}"); + assert_eq!(stream(&doc, img), before, "{what}"); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 290a410e14..848010bf5c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7373,8 +7373,8 @@ milestone = "M11" status = "shipped" commands = ["optimize.reduce"] tools = ["doc_reduce"] -evidence = ["crates/ui-egui/tests/it/create.rs::reduce_file_size_writes_a_compact_copy", "crates/ui-egui/tests/it/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] -notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), black-and-white images averaged down to 300 ppi above 450 ppi, and stored as CCITT Group 4 whenever that is smaller, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" +evidence = ["crates/ui-egui/tests/it/create.rs::reduce_file_size_writes_a_compact_copy", "crates/optimize/tests/optimize.rs::a_picture_with_a_stencil_mask_is_downsampled_under_its_mask", "crates/optimize/tests/optimize.rs::a_picture_whose_mask_is_not_a_stencil_is_left_alone", "crates/ui-egui/tests/it/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] +notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), black-and-white images averaged down to 300 ppi above 450 ppi, and stored as CCITT Group 4 whenever that is smaller, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; pictures under a stencil mask are included and the mask stays as it is, though samples it hides still count when resampling, so their colour can reach its edge; pictures with a colour-key mask are left alone; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" [[feature]] id = "optimize.compatibility-level" From 8582c84da8ab1beef52cacd5c59810a8b05f4d8f Mon Sep 17 00:00:00 2001 From: Piohrun Date: Tue, 3 Nov 2026 06:33:04 +0100 Subject: [PATCH 12/16] M11: Optimize runs when a category it discards holds nothing (#5742) * M11: Optimize runs when a category it discards holds nothing PDF Optimizer's Discard Objects ticks pass their categories to remove_hidden, which refuses a run that removes nothing. Ticking "Discard bookmarks" on a file without bookmarks, or doc_optimize with such a discard, ended the whole optimization with "there are no redaction marks to apply". An empty category is now nothing discarded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M8: doc_remove_hidden says when the listed categories hold nothing Its error came from remove_hidden's refusal: "there are no redaction marks to apply". It now checks the scan first, as redact_apply checks for marks, and says there is nothing to remove. The scan counts what removal takes (an AcroForm with no fields as 1), so the check refuses only what the removal would. The tool description says it fails then. The tool test also runs doc_optimize discarding categories that hold nothing, end to end. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: Optimize passes remove_hidden only the ticked categories that hold something Catching remove_hidden's refusal kept a copy it may have changed: with bookmarks ticked it drops an empty outline root before counting nothing, and its own documentation says to drop such a copy. The optimizer now scans first and passes only the ticked categories the scan finds something in, and with none doesn't call it, so an empty outline root stays, as Remove Hidden Information leaves it. Corrects 278a588bf's message: the ticks are under Discard Objects and Discard User Data, and hidden text is one of them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M8: doc_remove_hidden reads one scan, and says why it can't edit A document that can't be edited has no scan, so the check read every category as empty and said the listed ones hold nothing; the reason it can't be edited comes through again. The check and the count of what was removed now share the one scan the tool took already. Corrects ada2a1ccb's message: the removal also takes what its scan counts as 0 (an empty outline root); what the check matches is that the removal refuses when its scan counts every listed category as 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: the optimizer test discards a category that holds something Mutants of the category filter survived: no test ticked a category that holds something, or an empty one beside an unticked one that does. A page with a comment and no bookmarks, discarding both, discards the comment and only it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: Optimize passes every ticked category, and keeps the copy from before a refusal c7c018ac6 passed remove_hidden only the ticked categories its scan finds something in. But with form fields ticked, discarding hidden text also takes the hidden text the flattened appearances bring onto the page, which no scan before flattening counts, so with Flatten form fields and Discard hidden text ticked, the hidden text was dropped from the list and stayed in the file, where main removes it. Every ticked category goes to remove_hidden again, as on main. When it refuses (every category counts 0), it may have changed the copy (an empty outline root goes with bookmarks, and then it refuses), so the copy from before the call is kept and nothing is discarded. That also drops the extra scan c7c018ac6 added. Corrects c7c018ac6's message: remove_hidden counts first, then drops an empty outline root, then refuses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --------- Co-authored-by: Claude Opus 5.5 --- crates/automation/src/redact.rs | 20 ++++++-- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 11 +++++ crates/engine/src/optimizer.rs | 19 +++++++- crates/engine/src/tests.rs | 60 ++++++++++++++++++++++++ 5 files changed, 107 insertions(+), 5 deletions(-) diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index e662bcac19..a146f3f8ff 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -201,8 +201,8 @@ impl Automation { } pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result { - let edit = match a.get("categories") { - None => Edit::Sanitize, + let which = match a.get("categories") { + None => None, Some(_) => { let which = a .strs("categories")? @@ -212,10 +212,24 @@ impl Automation { if which.is_empty() { return Err(ToolError::InvalidArgs("categories is empty".into())); } + Some(which) + } + }; + // One scan, for this check and for what was removed. The removal refuses when it counts + // every listed category as 0 (an AcroForm with no fields counts 1), with an error about + // redaction marks; this reads the same scan, so it refuses exactly then. A document that + // can't be edited has no scan: `apply` says why. + let info = self.doc(a)?.hidden_info(); + let edit = match which { + None => Edit::Sanitize, + Some(which) => { + if !info.is_empty() && which.iter().all(|h| info.iter().find(|c| c.0 == *h).is_none_or(|c| c.1 == 0)) { + return Err(failed("nothing to remove: the listed categories hold nothing (see doc_hidden_info)")); + } Edit::RemoveHidden { which } } }; - let before: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum(); + let before: usize = info.iter().map(|c| c.1).sum(); let mut out = self.apply(a, edit)?; let after: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum(); out["removed"] = json!(before.saturating_sub(after)); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 7cafcb4a37..ba5892a451 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -562,7 +562,7 @@ pub fn tools() -> Vec { t( "doc_remove_hidden", "Remove hidden information / Sanitize", - "Remove the listed categories (see doc_hidden_info), or every category when none are listed (Sanitize Document). Form fields are flattened so their values stay visible. The next save rewrites the whole file. Undoable until saved.", + "Remove the listed categories (see doc_hidden_info), or every category when none are listed (Sanitize Document). Fails when the listed categories hold nothing. Form fields are flattened so their values stay visible. The next save rewrites the whole file. Undoable until saved.", ) .destructive() .with(schema( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 0b0a7d598e..c9b50755e8 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4670,12 +4670,23 @@ fn removing_hidden_information_through_tools() { assert_eq!(r["undo"], "Remove hidden information"); assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0); assert!(count(&ok(&mut a, "doc_hidden_info", json!({ "doc": doc })), "metadata") >= 1, "only comments went"); + // With none left, removing them again says so, and Optimize discarding them still runs. + let again = a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["comments"] })); + assert!(matches!(&again, Err(ToolError::Failed(m)) if m.starts_with("nothing to remove")), "{again:?}"); + let optimized = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "optimized.pdf", "discard": ["comments", "bookmarks"] })); + assert_eq!(optimized["discarded"], json!([]), "{optimized}"); let r = ok(&mut a, "doc_remove_hidden", json!({ "doc": doc })); assert_eq!(r["undo"], "Sanitize document"); ok(&mut a, "doc_save", json!({ "doc": doc, "path": "clean.pdf" })); let bytes = std::fs::read(dir.join("clean.pdf")).unwrap(); assert!(!bytes.windows(11).any(|w| w == b"Secret plan"), "the old revision is gone"); assert!(matches!(a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["nonsense"] })), Err(ToolError::InvalidArgs(_)))); + // A document that can't be edited has no scan: the refusal says why, not that nothing is there. + let junk = [vec![b'x'; 5000], std::fs::read(dir.join("a.pdf")).unwrap()].concat(); + std::fs::write(dir.join("junk.pdf"), junk).unwrap(); + let read_only = ok(&mut a, "doc_open", json!({ "path": "junk.pdf" }))["doc"].as_u64().unwrap(); + let refused = a.call("doc_remove_hidden", &json!({ "doc": read_only, "categories": ["comments"] })); + assert!(matches!(&refused, Err(ToolError::Failed(m)) if m.contains("can't be edited")), "{refused:?}"); } /// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle. diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index 42af737bab..30a14c5695 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -92,7 +92,24 @@ impl OptimizeJob { pdfcraft_xfa::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)? }; + let discarded = if discard.is_empty() { + Vec::new() + } else { + // Every ticked category, as `remove_hidden` decides between them: with form fields + // ticked, discarding hidden text also takes the hidden text the flattened appearances + // bring onto the page, which no scan before flattening counts. It refuses when its scan + // counts every one as 0, which would end the whole optimization, and may have changed + // the copy by then (an empty outline root goes with bookmarks), so the copy from before + // is kept instead: nothing is discarded. + let before = cos.clone(); + match pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard) { + Err(pdfcraft_redact::RedactError::NothingToApply) => { + cos = before; + Vec::new() + } + r => r?, + } + }; let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| { step(match s { optimize::Stage::Fragments => OptimizeStage::Fragments, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 25a185e835..d60ed04d66 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3610,6 +3610,66 @@ fn create_and_reduce() { assert_eq!(page_texts(&s3, r), ["Page 1", "Page 1"]); } +/// Discarding a category that holds nothing discards nothing; it used to end the whole +/// optimization with an error about redaction marks. +#[test] +fn optimize_discarding_an_empty_category_still_runs() { + let (s, id) = session_with(2); + let (bytes, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks, crate::Hidden::Comments]).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + assert!(report.discarded.is_empty(), "{:?}", report.discarded); + // An outline root with no entries is no bookmarks: it stays, as Remove Hidden Information, + // which refuses then, leaves it. + let outline = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R /PageMode /UseOutlines >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .obj("<< /Type /Page /Parent 2 0 R >>") + .obj("<< /Type /Outlines /Count 0 >>") + .build(); + let mut s = Session::new(); + let id = s.open("outline.pdf", None, Arc::new(outline), None).unwrap(); + let (bytes, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks]).unwrap(); + assert!(report.discarded.is_empty(), "{:?}", report.discarded); + let saved = pdfcraft_cos::Document::open(bytes).unwrap(); + let catalog = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap(); + assert!(catalog.contains(b"Outlines") && catalog.contains(b"PageMode"), "{catalog:?}"); + // Beside an empty tick, one that holds something is discarded, and only that one. + let noted = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .obj("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>") + .obj("<< /Type /Annot /Subtype /Text /Rect [20 20 40 40] /Contents (internal note) >>") + .build(); + let mut s = Session::new(); + let id = s.open("noted.pdf", None, Arc::new(noted), None).unwrap(); + let (_, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks, crate::Hidden::Comments]).unwrap(); + assert_eq!(report.discarded, vec![(crate::Hidden::Comments, 1)]); + // A field whose appearance draws text invisibly: flattening it brings that text onto the page, + // which no scan before counts as hidden, and discarding hidden text takes it too. + let form = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/F1 0 Tf 0 g) >> >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .obj("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] /Resources << /Font << /F1 6 0 R >> >> /Contents 7 0 R >>") + .obj("<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (shown) /Rect [100 100 300 130] /P 3 0 R /F 4 /AP << /N 5 0 R >> >>") + .stream( + "/Type /XObject /Subtype /Form /BBox [0 0 200 30] /Resources << /Font << /F1 6 0 R >> >>", + "/Tx BMC BT /F1 12 Tf 2 10 Td (shown) Tj ET BT /F1 12 Tf 3 Tr 2 20 Td (HIDDENSECRET) Tj ET EMC", + ) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .stream("", "BT /F1 12 Tf 72 700 Td (Page text) Tj ET") + .build(); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(form), None).unwrap(); + let (bytes, _) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::FormFields, crate::Hidden::HiddenText]).unwrap(); + let saved = pdfcraft_cos::Document::open(bytes).unwrap(); + for (r, object) in saved.scan_objects() { + if let pdfcraft_cos::Object::Stream(st) = &*object { + let data = st.decoded().unwrap_or_default(); + assert!(!data.windows(12).any(|w| w == b"HIDDENSECRET"), "the hidden text stays in {r:?}"); + } + } +} + #[test] fn an_optimize_job_reports_its_stages_and_can_be_cancelled() { use crate::optimizer::OptimizeStage; From bf0f3c9f775d747e3cc38d1de56a8e0f82e1e7e0 Mon Sep 17 00:00:00 2001 From: "Hasan N." <42965049+hasannadeem@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:33:11 +0500 Subject: [PATCH 13/16] M10: macOS print jobs name their title and owner (#5743) (#5757) * M10: macOS print jobs name their title and owner (#5743) lp sends no com.apple.print.JobInfo.* attributes, and Kyocera's macOS filter crashes without PMJobName and PMJobOwner, so the job stops with cups-filter-crashed and nothing prints. Both go to lp as job options, in the form lp_args passes on: printable ASCII, up to 64 bytes. * M10: keep macOS job metadata as the name CUPS should send (#5743) Review of #5757, checked against this Mac's libcups in memory: - `'` and `\` were consumed as quoting and `{` opened a collection, and a bare true/false became a boolean, so a filter reading PMJobName got nothing or the wrong type. They now become `_`, or the fallback. - CUPS keeps the last of a repeated option, ignoring case, so the defaults replaced a value a doc_print caller had set. They are now added only for a key the job leaves out. - Nothing proved the job info reached the spooler. A test now runs both submit paths against a stand-in `lp` on PATH. --- ROADMAP.md | 2 + crates/print/README.md | 4 +- crates/print/src/spool.rs | 52 +++++++++++++++++- crates/print/src/tests.rs | 101 +++++++++++++++++++++++++++++++++++ docs/roadmap.md | 1 + parity/acrobat-features.toml | 4 +- 6 files changed, 159 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a39d6f8b6c..0afbdb7cfd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -219,6 +219,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer. + - **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged. - **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another. diff --git a/crates/print/README.md b/crates/print/README.md index 1379be2b42..5db83312ca 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -46,7 +46,9 @@ Headless `doc_print` defaults are unchanged. `spool` delegates discovery, driver options, preferences and submission to [`craft-print`](https://github.com/storytold/craft-libs/tree/main/crates/print). PdfCraft retains page selection, imposition, annotations and print-permission checks. -CUPS jobs stream through stdin with `fit-to-page=false`. Windows renders sheets through +CUPS jobs stream through stdin with `fit-to-page=false`; on macOS they also carry the job +name and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`) as the system print dialog +sends them, which some vendors' filters need (#5743). Windows renders sheets through `Windows.Data.Pdf` and spools them at their physical PDF size with per-page paper and orientation, zero added margins and hardware-edge clipping. Long-edge and short-edge duplex use the corresponding Windows binding edges. Random, exclusively created Windows job files diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 5ce3d9736b..a83da3ed26 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -143,14 +143,62 @@ pub(crate) fn printer_options_via_within( options } +/// The keys the macOS print dialog names a job's title and owner under. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const JOB_NAME_KEY: &str = "com.apple.print.JobInfo.PMJobName"; +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const JOB_OWNER_KEY: &str = "com.apple.print.JobInfo.PMJobOwner"; + +/// `text` as a job option's value: `lp_args` passes on only printable ASCII, up to 64 bytes, +/// without `/ : = "`, and CUPS would take `'` and `\` as quoting and `{` as the start of a +/// collection, so all of these become `_`. Text with nothing left of it, such as a title in +/// another script, takes `fallback`: the job must still name something. So does a bare `true` or +/// `false`, which CUPS would send as a boolean, not the name a filter reads. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn job_option_value(text: &str, fallback: &str) -> String { + let kept = |c: char| c.is_ascii_graphic() && !matches!(c, '/' | ':' | '=' | '"' | '\'' | '\\' | '{'); + let value: String = text.chars().take(64).map(|c| if kept(c) { c } else { '_' }).collect(); + if !text.chars().any(kept) || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false") { + return fallback.to_string(); + } + value +} + +/// `job` naming its title and `owner` as a job from the macOS print dialog does. `lp` sends +/// neither, and a driver's filter can depend on them: Kyocera's crashes without them, so nothing +/// prints, while the same file sent with both does (#5743). A value the caller set stands: the +/// last of a repeated option wins in CUPS, which compares their names without case. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub(crate) fn with_job_info(job: &Job, owner: Option<&str>) -> Job { + let mut named = job.clone(); + for (key, value) in [(JOB_NAME_KEY, job.title.as_str()), (JOB_OWNER_KEY, owner.unwrap_or_default())] { + if !job.options.iter().any(|(k, _)| k.eq_ignore_ascii_case(key)) { + named.options.push((key.to_string(), job_option_value(value, "PdfCraft"))); + } + } + named +} + +/// The job as the system spooler takes it. +fn spooled(job: &Job) -> Job { + #[cfg(target_os = "macos")] + { + with_job_info(job, std::env::var("USER").ok().as_deref()) + } + #[cfg(not(target_os = "macos"))] + { + job.clone() + } +} + pub fn submit(pdf: &[u8], job: &Job) -> Result { - craft_print::submit(pdf, job).map_err(|e| crate::PrintError::Spool(e.to_string())) + craft_print::submit(pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string())) } /// [`submit`], told `(sent, sheets)` as each sheet reaches the spooler (Windows; CUPS takes the /// whole PDF at once and reports nothing). pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usize, usize)) -> Result { - craft_print::submit_with_progress(pdf, job, progress).map_err(|e| crate::PrintError::Spool(e.to_string())) + craft_print::submit_with_progress(pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string())) } pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> { diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 885756e8c6..642af0b8c9 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -305,6 +305,107 @@ fn spooler_arguments_and_printer_list() { assert_eq!(lp_args(&Job::default())[0], "-n", "no -d: the default printer"); } +/// The job name and owner, read from the `lp` arguments, since `lp_args` drops a value it can't +/// pass on: the job must reach the spooler named, whatever its title holds (#5743). +#[test] +fn a_job_names_its_title_and_owner_to_the_spooler() { + let args_for = |title: &str, owner: Option<&str>| { + let job = Job { title: title.into(), ..Job::default() }; + lp_args(&spool::with_job_info(&job, owner)) + }; + let option = |args: &[String], key: &str| { + let prefix = format!("com.apple.print.JobInfo.{key}="); + args.iter().find_map(|a| a.strip_prefix(prefix.as_str()).map(str::to_string)) + }; + let args = args_for("memo.pdf", Some("sam")); + assert_eq!(option(&args, "PMJobName").as_deref(), Some("memo.pdf"), "{args:?}"); + assert_eq!(option(&args, "PMJobOwner").as_deref(), Some("sam"), "{args:?}"); + // The title keeps its place as `-t`, in full. + assert!(args_for("Q3 report: a/b=\"c\".pdf", None).windows(2).any(|w| w[0] == "-t" && w[1] == "Q3 report: a/b=\"c\".pdf")); + + let named = args_for("Q3 report: a/b=\"c\".pdf", None); + assert_eq!(option(&named, "PMJobName").as_deref(), Some("Q3_report__a_b__c_.pdf"), "what lp can't be given becomes _"); + assert_eq!(option(&named, "PMJobOwner").as_deref(), Some("PdfCraft"), "no owner known: the filter still gets one"); + + for nothing_left in ["", " ", "見積書", "/:=\""] { + assert_eq!(option(&args_for(nothing_left, Some("")), "PMJobName").as_deref(), Some("PdfCraft"), "{nothing_left:?}"); + } + assert_eq!(option(&args_for("見積書2.pdf", None), "PMJobName").as_deref(), Some("___2.pdf"), "a part of it is kept"); + let long = option(&args_for(&format!("a{}", "é".repeat(200)), None), "PMJobName").unwrap_or_default(); + assert_eq!(long.len(), 64, "cut to what lp_args takes, on a multi-byte title too: {long}"); + + // What CUPS would quote away or send as another type, as this Mac's libcups does. + for (title, name) in [("it's.pdf", "it_s.pdf"), (r"a\b.pdf", "a_b.pdf"), ("{draft}.pdf", "_draft}.pdf"), ("a{b", "a_b")] { + assert_eq!(option(&args_for(title, None), "PMJobName").as_deref(), Some(name), "{title:?}"); + } + for boolean in ["true", "TRUE", "false", "False"] { + assert_eq!(option(&args_for(boolean, Some(boolean)), "PMJobName").as_deref(), Some("PdfCraft"), "{boolean:?}"); + assert_eq!(option(&args_for(boolean, Some(boolean)), "PMJobOwner").as_deref(), Some("PdfCraft"), "{boolean:?}"); + } + assert_eq!(option(&args_for("true.pdf", None), "PMJobName").as_deref(), Some("true.pdf"), "only the bare word is a boolean"); +} + +/// A job's own values stand: CUPS keeps the last of a repeated option, ignoring the case of its +/// name, so a default after them would replace what a `doc_print` caller set (#5743). +#[test] +fn a_job_keeps_the_title_and_owner_it_already_sets() { + let values = |job: &Job, key: &str| -> Vec { + let found = spool::with_job_info(job, Some("sam")).options; + found.into_iter().filter(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v).collect() + }; + let (name, owner) = ("com.apple.print.JobInfo.PMJobName", "com.apple.print.JobInfo.PMJobOwner"); + let job = Job { title: "memo.pdf".into(), options: vec![(name.to_string(), "Mine".to_string())], ..Job::default() }; + assert_eq!(values(&job, name), ["Mine"]); + assert_eq!(values(&job, owner), ["sam"], "the one it left out still gets its default"); + + let job = Job { title: "memo.pdf".into(), options: vec![(owner.to_ascii_uppercase(), "Theirs".to_string())], ..Job::default() }; + assert_eq!(values(&job, owner), ["Theirs"]); + assert_eq!(values(&job, name), ["memo.pdf"]); +} + +/// Both ways to submit reach `lp` with the job named. `lp` comes from `PATH`, which only a child +/// process can change here, so the test runs itself again with a stand-in that records its +/// arguments; no job reaches a real spooler (#5743). +#[cfg(target_os = "macos")] +#[test] +fn a_submitted_job_reaches_lp_named() { + const TEST: &str = "tests::a_submitted_job_reaches_lp_named"; + if let Ok(way) = std::env::var("PDFCRAFT_SUBMIT_TO_STAND_IN") { + let job = Job { title: "memo.pdf".into(), ..Job::default() }; + let sent = match way.as_str() { + "submit" => spool::submit(b"%PDF-1.7", &job), + _ => spool::submit_with_progress(b"%PDF-1.7", &job, &mut |_, _| {}), + }; + assert!(sent.is_ok(), "{sent:?}"); + return; + } + use std::os::unix::fs::PermissionsExt as _; + let dir = std::env::temp_dir().join(format!("pdfcraft-lp-stand-in-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let lp = dir.join("lp"); + let recorded = dir.join("args"); + let script = + format!("#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\n/bin/cat > /dev/null\necho 'request id is stand-in-1 (1 file(s))'\n", recorded.display()); + std::fs::write(&lp, script).expect("stand-in"); + std::fs::set_permissions(&lp, std::fs::Permissions::from_mode(0o700)).expect("executable"); + for way in ["submit", "submit_with_progress"] { + let _ = std::fs::remove_file(&recorded); + let this = std::env::current_exe().expect("this test binary"); + let child = std::process::Command::new(this) + .args(["--exact", TEST, "--nocapture"]) + .env("PDFCRAFT_SUBMIT_TO_STAND_IN", way) + .env("PATH", &dir) + .env("USER", "sam") + .output() + .expect("run the test again"); + assert!(child.status.success(), "{way}: {}", String::from_utf8_lossy(&child.stdout)); + let args = std::fs::read_to_string(&recorded).expect("lp was run"); + assert!(args.lines().any(|a| a == "com.apple.print.JobInfo.PMJobName=memo.pdf"), "{way}: {args}"); + assert!(args.lines().any(|a| a == "com.apple.print.JobInfo.PMJobOwner=sam"), "{way}: {args}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn lpstat_output_is_untranslated() { // A localized lpstat (here Polish) is unreadable to parse_lpstat... diff --git a/docs/roadmap.md b/docs/roadmap.md index 673e9053fe..c691058933 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -298,6 +298,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | patch | M10 #5743: macOS print jobs carry the job name and owner some vendors' filters need; not yet checked on a physical printer | | 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. | | 2026-10-31 | patch | M3 #1354: reconcile the launch override with split app/file modules and the structured open result; preserve headless help/version exits. CI validation pending. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 848010bf5c..2fe849a6dc 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8085,8 +8085,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["printers", "printer_options", "doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/workflows/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/workflows/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/workflows/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"] -notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818)" +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::a_job_names_its_title_and_owner_to_the_spooler", "crates/print/src/tests.rs::a_job_keeps_the_title_and_owner_it_already_sets", "crates/print/src/tests.rs::a_submitted_job_reaches_lp_named", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/workflows/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/workflows/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/workflows/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"] +notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818); on macOS lp jobs also carry the job name and owner (com.apple.print.JobInfo.PMJobName and PMJobOwner) that the system print dialog sends and some vendors' filters need (#5743). Not yet checked on a physical Kyocera printer." [[feature]] id = "print.copies-collate" From 5cb65ff0eecd2149eb32b1f6e61becc96070a44b Mon Sep 17 00:00:00 2001 From: Ken Mantle Date: Tue, 3 Nov 2026 00:33:18 -0500 Subject: [PATCH 14/16] feat(ui-egui): Preferences can invert the middle-button drag (#5790) (#5817) Add a saved preference, invert_middle_drag (off by default), that reverses the direction of the middle-button pan and of the latched auto-scroll. It changes only the direction, not which of the two a platform uses, so it composes with the Windows auto-scroll work. Reachable as Preferences > Invert middle-button drag and headlessly through ui.set invert_middle_drag. Parity "Automatic scrolling" notes updated; status unchanged. Tests: a unit test of both paths with the switch on and off (dead zone kept), a settings round-trip test, and an end-to-end control-channel drag. Each fails with its fix reverted. Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 Co-authored-by: KenM76 Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/control.rs | 3 ++ crates/ui-egui/src/app/prefs.rs | 4 ++ crates/ui-egui/src/canvas.rs | 9 ++-- crates/ui-egui/src/canvas/autoscroll.rs | 63 ++++++++++++++++++++--- crates/ui-egui/src/canvas/organize.rs | 3 +- crates/ui-egui/src/dialogs/preferences.rs | 7 +++ crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/tests/it/control.rs | 19 +++++++ crates/ui-egui/tests/it/viewer.rs | 16 ++++++ parity/acrobat-features.toml | 4 +- 11 files changed, 117 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index c326b479fe..6ac8ea8783 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -99,6 +99,7 @@ impl PdfCraftApp { #[cfg(target_os = "linux")] desktop_theme_enabled: false, scroll_speed: scroll_speed::ScrollSpeed::default(), + invert_middle_drag: false, animation_effects: motion::AnimationEffects::default(), motion: motion::Motion::default(), language: i18n::AUTO.to_string(), diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index e4078efd13..0309042bf1 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -50,6 +50,9 @@ impl PdfCraftApp { ("scroll_speed", _) => { self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?; } + ("invert_middle_drag", _) => { + self.invert_middle_drag = value.parse::().map_err(|_| "invert_middle_drag must be true or false")?; + } ("animation_effects", _) => { self.animation_effects = motion::AnimationEffects::parse(value).ok_or("animation_effects must be system, on, or off")?; } diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 7eddee0af0..d3db335d83 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -89,6 +89,7 @@ impl PdfCraftApp { "pinned_folders": self.pinned.folders, "theme": self.theme_preference, "scroll_speed": self.scroll_speed, + "invert_middle_drag": self.invert_middle_drag, "animation_effects": self.animation_effects, "default_mode": self.default_mode, "default_layout": canvas::display_token(self.view_defaults.layout, self.view_defaults.continuous), @@ -237,6 +238,9 @@ impl PdfCraftApp { if let Ok(speed) = serde_json::from_value::(v["scroll_speed"].clone()) { self.scroll_speed = speed; } + if let Some(invert) = v["invert_middle_drag"].as_bool() { + self.invert_middle_drag = invert; + } if let Ok(effects) = serde_json::from_value::(v["animation_effects"].clone()) { self.animation_effects = effects; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 56d2674213..3879cd9344 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3338,9 +3338,9 @@ fn pinch_zoom(ui: &egui::Ui, view: &mut DocView, zoom_delta: f32, navigation_rec /// The middle-button auto-scroll's delta, and the edge scroll a drawing in progress asks for: the /// pointer in a band along any edge (or just past it) scrolls the view that way, so a point /// beyond what is shown can be reached without letting go of the tool. -fn auto_scroll_deltas(ui: &egui::Ui, view: &mut DocView, avail: Rect, unobstructed: bool) -> (Vec2, Vec2) { +fn auto_scroll_deltas(ui: &egui::Ui, view: &mut DocView, avail: Rect, unobstructed: bool, invert: bool) -> (Vec2, Vec2) { let auto_delta = if unobstructed { - view.auto_scroll.update(ui, avail, false) + view.auto_scroll.update(ui, avail, false, invert) } else { view.auto_scroll.cancel(); Vec2::ZERO @@ -3517,11 +3517,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let modal = app.modal_open(); let unobstructed = !modal && !app.palette_open; let organize_zoom = app.organize_zoom; + let invert_middle = app.invert_middle_drag; let view = &mut app.views[index]; if view.organize { // One remembered grid size for every document, kept in the settings. view.set_grid_zoom(organize_zoom); - organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, ui, &t); + organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, invert_middle, ui, &t); app.organize_zoom = view.grid_zoom(); return; } @@ -3541,7 +3542,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let present = presentation_click_state(ui, view, navigation_rect, navigation_enabled && app.quick_tool == QuickTool::Select); pinch_zoom(ui, view, zoom_delta, navigation_rect, navigation_enabled); view.viewport_screen = avail; - let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed); + let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed, invert_middle); let content_w = view.content_width(info, avail.width()); let rects = view.layout(info, content_w, ui.ctx().pixels_per_point()); diff --git a/crates/ui-egui/src/canvas/autoscroll.rs b/crates/ui-egui/src/canvas/autoscroll.rs index 14751ffc5c..1f4cd62c5d 100644 --- a/crates/ui-egui/src/canvas/autoscroll.rs +++ b/crates/ui-egui/src/canvas/autoscroll.rs @@ -46,15 +46,18 @@ impl AutoScroll { } /// Run before the document's widgets. Starting is restricted to the unobstructed viewport; - /// once started, moving outside that viewport still controls the speed. - pub(crate) fn update(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool) -> Vec2 { - self.update_for_platform(ui, viewport, organize, cfg!(target_os = "linux")) + /// once started, moving outside that viewport still controls the speed. `invert` is + /// Preferences ▸ Invert middle-button drag: the returned delta scrolls the other way. + pub(crate) fn update(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool, invert: bool) -> Vec2 { + self.update_for_platform(ui, viewport, organize, cfg!(target_os = "linux"), invert) } - fn update_for_platform(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool, supported: bool) -> Vec2 { - if !supported { - return self.update_drag(ui, viewport); - } + fn update_for_platform(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool, supported: bool, invert: bool) -> Vec2 { + let delta = if supported { self.update_latched(ui, viewport, organize) } else { self.update_drag(ui, viewport) }; + oriented(delta, invert) + } + + fn update_latched(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool) -> Vec2 { let ctx = ui.ctx(); let (pointer, middle_press, middle_down, middle_released, cancel, cancel_button, dt) = ctx.input(|i| { let cancel_button = [PointerButton::Primary, PointerButton::Secondary, PointerButton::Extra1, PointerButton::Extra2] @@ -237,6 +240,11 @@ pub(crate) fn edge_delta(y: f32, viewport: egui::Rect, dt: f32) -> f32 { -distance.clamp(-MAX_DISPLACEMENT, MAX_DISPLACEMENT) * EDGE_SPEED * dt.clamp(0.0, 0.05) } +/// The middle button's delta, reversed when the user inverted it. +fn oriented(delta: Vec2, invert: bool) -> Vec2 { + if invert { -delta } else { delta } +} + fn scroll_delta(displacement: f32, dt: f32) -> f32 { if !displacement.is_finite() || !dt.is_finite() { return 0.0; @@ -261,10 +269,15 @@ mod tests { /// Run one frame of the non-Linux path; `check` sees the pan delta inside the frame. fn drag_frame(ctx: &Context, scroll: &mut AutoScroll, events: Vec, focused: bool, check: impl Fn(&AutoScroll, Vec2)) { + frame(ctx, scroll, events, focused, (false, false), check); + } + + /// Run one frame; `mode` is (latched auto-scroll supported, inverted). + fn frame(ctx: &Context, scroll: &mut AutoScroll, events: Vec, focused: bool, mode: (bool, bool), check: impl Fn(&AutoScroll, Vec2)) { let mut output = ctx.run_ui( egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events, focused, ..Default::default() }, |ui| { - let delta = scroll.update_for_platform(ui, ui.max_rect(), false, false); + let delta = scroll.update_for_platform(ui, ui.max_rect(), false, mode.0, mode.1); check(scroll, delta); }, ); @@ -272,6 +285,40 @@ mod tests { output.textures_delta.clear(); } + /// The pan delta after pressing at `p` and moving the pointer by `by`. + fn pan_by(by: Vec2, invert: bool) -> Vec2 { + let (ctx, mut scroll, p) = (Context::default(), AutoScroll::default(), egui::pos2(100.0, 100.0)); + frame(&ctx, &mut scroll, vec![], true, (false, invert), |_, _| {}); + frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (false, invert), |_, _| {}); + let moved = std::cell::Cell::new(Vec2::ZERO); + frame(&ctx, &mut scroll, vec![Event::PointerMoved(p + by)], true, (false, invert), |_, d| moved.set(d)); + moved.get() + } + + /// The auto-scroll delta in the frame after latching at `p`, with the pointer `dy` below it. + fn latched_by(dy: f32, invert: bool) -> Vec2 { + let (ctx, mut scroll, p) = (Context::default(), AutoScroll::default(), egui::pos2(100.0, 100.0)); + frame(&ctx, &mut scroll, vec![], true, (true, invert), |_, _| {}); + frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true), middle(p, false)], true, (true, invert), |_, _| {}); + let moved = std::cell::Cell::new(Vec2::ZERO); + frame(&ctx, &mut scroll, vec![Event::PointerMoved(p + vec2(0.0, dy))], true, (true, invert), |s, d| { + assert!(s.active(), "the click latched auto-scroll"); + moved.set(d); + }); + moved.get() + } + + #[test] + fn inverting_the_middle_button_reverses_the_pan_and_the_auto_scroll() { + let by = vec2(-30.0, 40.0); + assert_eq!(pan_by(by, false), by, "by default the page follows the pointer"); + assert_eq!(pan_by(by, true), -by, "inverted, the view moves the way the pointer goes"); + let (normal, inverted) = (latched_by(60.0, false), latched_by(60.0, true)); + assert!(normal.y < 0.0, "by default, below the anchor scrolls down: {normal:?}"); + assert_eq!(inverted, -normal, "inverted, the same distance scrolls the other way"); + assert_eq!(latched_by(10.0, true), Vec2::ZERO, "the dead zone still holds still"); + } + #[test] fn middle_drag_pans_one_to_one_and_owns_the_button_until_release() { let ctx = Context::default(); diff --git a/crates/ui-egui/src/canvas/organize.rs b/crates/ui-egui/src/canvas/organize.rs index 48c7e82d8f..25c99543e4 100644 --- a/crates/ui-egui/src/canvas/organize.rs +++ b/crates/ui-egui/src/canvas/organize.rs @@ -272,6 +272,7 @@ pub(super) fn organize_grid( doc: &pdfcraft_engine::Document, unobstructed: bool, show_page_edge_shadows: bool, + invert_middle: bool, ui: &mut egui::Ui, t: &Tokens, ) { @@ -288,7 +289,7 @@ pub(super) fn organize_grid( let viewport = ui.available_rect_before_wrap(); view.viewport_screen = viewport; let auto_delta = if unobstructed { - view.auto_scroll.update(ui, viewport, true) + view.auto_scroll.update(ui, viewport, true, invert_middle) } else { view.auto_scroll.cancel(); Vec2::ZERO diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 49609461d8..38cce3274b 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -759,7 +759,11 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, }); ctx.label_secondary(ui, tl!("For page sizes and margins."), t); ui.add_space(6.0); + scrolling(ui, app, t, ctx); +} +/// The scroll speed and the middle button's direction, in Card 3. +fn scrolling(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { ui.horizontal(|ui| { ctx.label_primary(ui, tl!("Scroll speed (mouse wheel and arrow keys)"), t); ctx.pill_choice( @@ -773,6 +777,9 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ], ); }); + ui.add_space(6.0); + ctx.checkbox(ui, &mut app.invert_middle_drag, tl!("Invert middle-button drag"), t); + ctx.label_secondary(ui, tl!("Panning or auto-scrolling with the middle (wheel) button moves the page the other way."), t); } /// Content for Card 4: Identity diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c25dae98e3..643d5e90ff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -311,6 +311,9 @@ pub struct PdfCraftApp { desktop_theme_enabled: bool, /// How far a mouse-wheel notch scrolls. pub scroll_speed: scroll_speed::ScrollSpeed, + /// Preferences ▸ Invert middle-button drag: the middle button's pan or auto-scroll moves the + /// view the other way. Off by default. + pub invert_middle_drag: bool, /// Preferences ▸ Animation effects: on, off, or as the system's animation setting says. pub animation_effects: motion::AnimationEffects, /// Whether the system asked for no animations, and how the desktop app asks it. diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 6a0abd749b..c1465b233d 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -916,6 +916,25 @@ fn middle_button_pans_while_held_outside_linux() { assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); } +/// With Invert middle-button drag on, a held middle drag moves the view the way the pointer goes. +#[cfg(not(target_os = "linux"))] +#[test] +fn inverted_middle_drag_moves_the_view_the_way_the_pointer_goes() { + let (mut h, c) = harness(); + h.state_mut().set_option("zoom", "400").unwrap(); + ok(&mut h, &c, "ui.set", json!({ "key": "invert_middle_drag", "value": "true" })); + h.run_steps(3); + h.state_mut().views[0].go_to_page(1); + h.run_steps(2); + let p = h.state().views[0].viewport_rect().center(); + let top = h.state().views[0].page_screen_rect(1).unwrap().top(); + ok(&mut h, &c, "ui.drag", json!({ "from": [p.x, p.y], "to": [p.x, p.y - 60.0], "steps": 12, "button": "middle" })); + h.run_steps(2); + let moved = h.state().views[0].page_screen_rect(1).unwrap().top(); + assert!((moved - (top + 60.0)).abs() < 1.0, "dragging up scrolls up, so the page moves down: {top} -> {moved}"); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); +} + #[test] fn state_and_view_options() { let (mut h, c) = harness(); diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 3d824b9ea1..6f7d3d3cc8 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -1838,6 +1838,22 @@ fn the_scroll_speed_preference_scales_mouse_wheel_notches() { assert_eq!(again.scroll_speed, ScrollSpeed::Fast); } +#[test] +fn the_invert_middle_drag_preference_is_off_by_default_and_kept_in_the_settings() { + let mut app = PdfCraftApp::new(); + assert!(!app.invert_middle_drag, "the middle button keeps its direction unless asked"); + app.set_option("invert_middle_drag", "true").unwrap(); + assert!(app.set_option("invert_middle_drag", "yes").unwrap_err().contains("true or false")); + let mut again = PdfCraftApp::new(); + again.restore(&app.persist()); + assert!(again.invert_middle_drag, "turned on, it survives a restart"); + again.set_option("invert_middle_drag", "false").unwrap(); + let mut off = PdfCraftApp::new(); + off.invert_middle_drag = true; + off.restore(&again.persist()); + assert!(!off.invert_middle_drag, "turned off, that is saved too"); +} + /// A 40 × 30 picture (one red corner) and a harness showing a PDF of it. fn picture_harness() -> (image::RgbaImage, Harness<'static, PdfCraftApp>) { picture_harness_sized(egui::vec2(1400.0, 900.0)) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2fe849a6dc..ab8f750190 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -938,8 +938,8 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/it/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/it/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/it/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views", "crates/ui-egui/tests/it/control.rs::middle_button_pans_while_held_outside_linux", "crates/ui-egui/tests/it/comments.rs::a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing"] -notes = "Linux-only middle-button vertical autoscroll in the document and Organize Pages; Windows and macOS pan 1:1 while the middle button is held (Grabbing cursor, no latch). On every target the middle button never reaches the page tools, so it scrolls instead of drawing, selecting, cropping or moving. Click to toggle, release keeps scrolling on, and a Chromium-style nonlinear curve increases speed with vertical screen distance from the initial click. A 15-point dead zone pauses immediately. Continuous redraws use elapsed frame time and preserve fractional movement, tested at 30/60/120/144 fps. Cancels on another middle click, Escape, other pointer/wheel input, focus loss, tab changes and dialogs. Driven headlessly through the opt-in UI control channel (ui.click/ui.move/ui.drag). Keyboard-started timed scrolling is not implemented." +evidence = ["crates/ui-egui/tests/it/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/it/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/it/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/it/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views", "crates/ui-egui/tests/it/control.rs::middle_button_pans_while_held_outside_linux", "crates/ui-egui/tests/it/comments.rs::a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing", "crates/ui-egui/tests/it/control.rs::inverted_middle_drag_moves_the_view_the_way_the_pointer_goes", "crates/ui-egui/tests/it/viewer.rs::the_invert_middle_drag_preference_is_off_by_default_and_kept_in_the_settings"] +notes = "Linux-only middle-button vertical autoscroll in the document and Organize Pages; Windows and macOS pan 1:1 while the middle button is held (Grabbing cursor, no latch). On every target the middle button never reaches the page tools, so it scrolls instead of drawing, selecting, cropping or moving. Click to toggle, release keeps scrolling on, and a Chromium-style nonlinear curve increases speed with vertical screen distance from the initial click. A 15-point dead zone pauses immediately. Continuous redraws use elapsed frame time and preserve fractional movement, tested at 30/60/120/144 fps. Cancels on another middle click, Escape, other pointer/wheel input, focus loss, tab changes and dialogs. Preferences ▸ Invert middle-button drag (off by default, saved; ui.set invert_middle_drag) reverses the direction of both the pan and the auto-scroll (#5790); it changes direction only, not which of the two a platform uses. Driven headlessly through the opt-in UI control channel (ui.click/ui.move/ui.drag). Keyboard-started timed scrolling is not implemented." [[feature]] id = "view.zoom-in-out" From d9b735973b0dce8387913c9a688c1fee4f9a6015 Mon Sep 17 00:00:00 2001 From: Rahul Date: Tue, 3 Nov 2026 11:03:26 +0530 Subject: [PATCH 15/16] i18n(fr): translate the 16 strings the report listed as missing (#5736) The French catalog lacked 16 interface strings: the crop-image and mouse-wheel-speed labels, the signature-validation findings (RSA key size, CMS content-type and message-digest rules, timestamp authority certificate), the certificate recognition sentence, the changed-on-disk reload warning, the password rule, the table-layout validations, and the Appearance theme name. Adds them in the catalog's TSV format. After this the report lists nothing missing for fr. Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- crates/ui-egui/src/i18n/fr.tsv | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1c22cf9d81..edfe48de44 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2882,3 +2882,21 @@ certificate Version Version No image on clipboard to paste Aucune image à coller dans le presse-papiers Pinned {tool} to Home {tool} épinglé à l’Accueil Unpinned {tool} from Home {tool} désépinglé de l’Accueil + +# The strings the report listed as missing for fr. + Crop image Recadrer l'image + Mouse wheel scroll speed Vitesse de défilement à la molette + Remove {standard} identification Supprimer l'identification {standard} + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Le certificat de {} comporte des extensions critiques que PdfCraft ne reconnaît pas ; il n'est donc pas utilisé pour attester {}. + The password must have at least 8 characters. Le mot de passe doit comporter au moins 8 caractères. + The signature's signed attributes have no content type, which CMS requires; it was read anyway. Les attributs signés de la signature n'ont pas de type de contenu, exigé par CMS ; elle a néanmoins été lue. + The signature's signed attributes have no message digest, so the signature does not cover the document. Les attributs signés de la signature n'ont pas de condensat de message ; la signature ne couvre donc pas le document. + The signature's signed attributes name a content type other than data, so it is not a signature of the document. Les attributs signés de la signature indiquent un type de contenu autre que des données ; ce n'est donc pas une signature du document. + The timestamp authority's certificate is not issued for time stamping. Le certificat de l'autorité d'horodatage n'est pas émis pour l'horodatage. + This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Ce fichier a changé sur le disque. Rechargez-le pour voir la nouvelle version ; vos modifications non enregistrées seront perdues. + 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. Cette signature utilise une clé RSA de moins de 2048 bits, ce qui n'est plus considéré comme suffisamment robuste pour des signatures. Elle se valide toujours, mais il ne faut pas s'y fier. + a table has 1 to {max} columns un tableau a de 1 à {max} colonnes + a table has 1 to {max} rows un tableau a de 1 à {max} lignes + the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 l'aire, l'espacement et les largeurs de colonnes doivent être des nombres, l'espacement 0 ou plus et chaque largeur supérieure à 0 + the first row number can be at most {max} le numéro de la première ligne peut être au maximum {max} +theme Appearance Apparence From 14ae839a28902a17b9c6adba68d820eb922544c4 Mon Sep 17 00:00:00 2001 From: Fatih Kunak Date: Tue, 3 Nov 2026 12:33:33 +0700 Subject: [PATCH 16/16] i18n(tr): translate the 288 strings the gaps report lists (#5795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n(tr): translate the 288 strings the gaps report lists The translation gaps report listed 288 English strings with no Turkish translation, among them the Window menu ("Pencere"), the side-panel and hotbar labels, the signature and timestamp validation messages, the accessibility checker findings, the plurals and the calendar weekday heads. The two English stand-ins at the top of the catalog are translated too. `cargo xtask i18n-report` now lists 0 missing for tr. Terms follow the existing catalog (annotation = açıklama, digital ID = dijital kimlik, bookmark = yer işareti, redact = karart). Placeholders and trailing ellipses match the English source. AI-assisted; not separately reviewed line by line by a native speaker. Co-Authored-By: Claude Opus 5.5 * i18n(tr): note the native review of the judgement calls A native Turkish speaker reviewed the translations the PR flagged as judgement calls ("of" as a slash, hotbar, navigation rail, the certificate key-usage terms, the weekday heads) and kept them. The rest stay AI-assisted, so full proofreading is still pending. Co-Authored-By: Claude Opus 5.5 * docs(i18n): state the Turkish coverage by the gaps report, not the dated union The 2,798-key union in localization-parity.md was measured on 2026-10-25 and predates keys added since, so "every current key" against it did not add up (CodeRabbit). The Turkish row now says what was measured: 2,867 entries and 0 missing in `cargo xtask i18n-report` on 2026-11-03. Co-Authored-By: Claude Opus 5.5 * Revert the vendored Cargo.lock changes c700ce42 picked up by mistake They came from running the vendored crates' tests locally and have nothing to do with this PR. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/i18n/tr.tsv | 296 ++++++++++++++++++++++++++++++++- docs/localization-parity.md | 4 +- 2 files changed, 295 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6921a6c33e..8a0d7bf74b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4,10 +4,6 @@ # # Clean-room: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources. -# Untranslated: English text until a translator fills these in. - Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. - Recent files to keep Recent files to keep - Menu Menü File Dosya Edit Düzen @@ -2613,3 +2609,295 @@ animation effects Off Kapalı No image on clipboard to paste Panoda yapıştırılacak görüntü yok Pinned {tool} to Home {tool} Ana Sayfaya sabitlendi Unpinned {tool} from Home {tool} Ana Sayfadan kaldırıldı + +# Strings the translation gaps report listed on 2026-11-03 (AI-assisted; a native Turkish speaker reviewed the judgement calls: "of", hotbar, navigation rail, certificate terms, weekday heads). + Lowering this forgets the oldest files. At 0 there is no list. Bu değeri düşürmek en eski dosyaları unutturur. 0'da liste olmaz. + Recent files to keep Saklanacak son dosya sayısı + on page {label} {label}. sayfada + 1 line 1 satır + A font isn't embedded Bir yazı tipi gömülü değil + A {} action isn't allowed {} eylemine izin verilmiyor + A {} annotation has additional actions Bir {} açıklamasının ek eylemleri var + A {} annotation has no appearance stream Bir {} açıklamasının görünüm akışı yok + A {} annotation isn't set to print, or is hidden Bir {} açıklaması yazdırılacak şekilde ayarlanmamış ya da gizli + Accessibility Report Erişilebilirlik Raporu + Add tools to hotbar Kısayol çubuğuna araç ekle + Always Her zaman + Automatically Otomatik + CRL Signing CRL İmzalama + Certificate Signing Sertifika İmzalama + Certificate authority Sertifika yetkilisi + Change Author Yazarı Değiştir + Change Keywords Anahtar Kelimeleri Değiştir + Change Subject Konuyu Değiştir + Change Title Başlığı Değiştir + Choose a digital ID. Bir dijital kimlik seçin. + Choose a file… Dosya seç… + Choose a folder… Klasör seç… + Choose… Seç… + Close menu Menüyü kapat + Color Renk + Comment Yorum + Comment and review Yorumla ve incele + Copied to clipboard Panoya kopyalandı + Copy to clipboard Panoya kopyala + Create folder Klasör oluştur + Create links Bağlantı oluştur + Creator Oluşturan + Crop image Resmi kırp + Custom scale Özel ölçek + Cut and stack needs Two-sided: Off. Print single-sided sheets. Kes ve istifle için Çift taraflı: Kapalı olmalı. Tek taraflı sayfalar yazdırın. + Data Encipherment Veri Şifreleme + Decipher Only Yalnızca Şifre Çözme + Detailed Report Ayrıntılı Rapor + DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile DeviceCMYK kullanılıyor ancak PDF/A çıktı amacı bir CMYK profili değil + DeviceRGB is used, but the PDF/A output intent isn't an RGB profile DeviceRGB kullanılıyor ancak PDF/A çıktı amacı bir RGB profili değil + Digital Signature Dijital İmza + Disabled Devre dışı + Dock navigation rail on Left Gezinme çubuğunu sola yerleştir + Dock navigation rail on Right Gezinme çubuğunu sağa yerleştir + Document properties (⌘D) Belge özellikleri (⌘D) + Drag hotbar Kısayol çubuğunu sürükle + Duplicate Çoğalt + Enabled Etkin + Encipher Only Yalnızca Şifreleme + End entity Son varlık + English İngilizce + Enter a URL Bir URL girin + Enter a name. Bir ad girin. + Enter a page number Bir sayfa numarası girin + Enter the field names Alan adlarını girin + Error Hata + Every line set in the style chosen for a level becomes a bookmark of that level, under a new first bookmark. Bir düzey için seçilen stille yazılmış her satır, yeni bir ilk yer işaretinin altında o düzeyin yer işareti olur. + Extend the docked toolbar along the entire side. Choose a side above to enable. Yerleştirilmiş araç çubuğunu kenar boyunca uzat. Etkinleştirmek için yukarıdan bir kenar seçin. + Field "{}" on page {p} has no description (tooltip) {p}. sayfadaki "{}" alanının açıklaması (araç ipucu) yok + Filename Dosya adı + Flatten Fill & Sign Doldur ve İmzala'yı düzleştir + Float (drag anywhere) Serbest (istediğin yere sürükle) + Folder Klasör + Font (unnamed) on page {p} doesn't map its characters to Unicode {p}. sayfadaki (adsız) yazı tipi karakterlerini Unicode'a eşlemiyor + Font {} on page {p} doesn't map its characters to Unicode {p}. sayfadaki {} yazı tipi karakterlerini Unicode'a eşlemiyor + Form field on page {p} isn't tagged {p}. sayfadaki form alanı etiketli değil + Freehand-Highlight Serbest Vurgulama + Full-side strip Tam kenar şeridi + General & Interface Genel ve Arayüz + Hand El + Help… Yardım… + Hotbar settings Kısayol çubuğu ayarları + Hotbar settings / add tools Kısayol çubuğu ayarları / araç ekle + Hotbar tools Kısayol çubuğu araçları + H{n} follows an H{d}; headings should not skip levels H{n}, bir H{d} ardından geliyor; başlıklar düzey atlamamalı + Import İçe aktar + Information Bilgi + Key Agreement Anahtar Anlaşması + Key Encipherment Anahtar Şifreleme + Level {n} Düzey {n} + Loading… Yükleniyor… + Mouse wheel scroll speed Fare tekerleği kaydırma hızı + Move Taşı + Navigation rail side Gezinme çubuğunun kenarı + Never Hiçbir zaman + New bookmark (⌘B) Yeni yer işareti (⌘B) + New bookmarks from text styles Metin stillerinden yeni yer işaretleri + New bookmarks from text styles… Metin stillerinden yeni yer işaretleri… + Next Sonraki + Next (⌘G) Sonraki (⌘G) + No document language is set Belge dili ayarlanmamış + No page has text: the document appears to be images of pages Hiçbir sayfada metin yok: belge sayfa resimlerinden oluşuyor gibi görünüyor + No results Sonuç yok + Non-Repudiation İnkâr Edilemezlik + On Açık + Open a PDF (⌘O) PDF aç (⌘O) + Open folder Klasörü aç + Organize Düzenle + Page Properties Sayfa Özellikleri + Page properties… Sayfa özellikleri… + Page {n} Sayfa {n} + Page {n} of {total} Sayfa {n} / {total} + Page {p} doesn't tab in structure order {p}. sayfanın sekme sırası yapı sırasını izlemiyor + Page {p}: {n} untagged content item Sayfa {p}: {n} etiketsiz içerik öğesi + Page {p}: {n} untagged content items Sayfa {p}: {n} etiketsiz içerik öğesi + Page {} has additional actions {}. sayfanın ek eylemleri var + Panel layout could not be restored: {error} Panel düzeni geri yüklenemedi: {error} + Panel move was not applied: {error} Panel taşıma uygulanmadı: {error} + PdfCraft can't check this signature yet: {}. PdfCraft bu imzayı henüz denetleyemiyor: {}. + Previous Önceki + Previous (⇧⌘G) Önceki (⇧⌘G) + Print (⌘P) Yazdır (⌘P) + Producer Üretici + Refresh Yenile + Remove from hotbar Kısayol çubuğundan kaldır + Remove {standard} identification {standard} tanımlamasını kaldır + Rendering… İşleniyor… + Report created Rapor oluşturuldu + Reset side panels Yan panelleri sıfırla + Rev. {rev}: Certified by an unknown signer Rev. {rev}: Bilinmeyen bir imzalayan tarafından onaylandı + Rev. {rev}: Certified by {name} Rev. {rev}: {name} tarafından onaylandı + Rev. {rev}: Signed by an unknown signer Rev. {rev}: Bilinmeyen bir imzalayan tarafından imzalandı + Rev. {rev}: Signed by {name} Rev. {rev}: {name} tarafından imzalandı + Rotate view clockwise (⇧⌘+) Görünümü saat yönünde döndür (⇧⌘+) + Rule Name Kural Adı + Save as {level} {level} olarak kaydet + Save changes? Değişiklikler kaydedilsin mi? + Save file Dosyayı kaydet + Search all tools… Tüm araçlarda ara… + Search results Arama sonuçları + Search settings… Ayarlarda ara… + Select Seç + Select all Tümünü seç + Show or hide the Tools panel Araçlar panelini göster veya gizle + Show or hide the side panel Yan paneli göster veya gizle + Signature is invalid İmza geçersiz + Signature is valid İmza geçerli + Signature validity is unknown İmzanın geçerliliği bilinmiyor + Signing time is from the clock on the signer's computer. İmzalama zamanı, imzalayanın bilgisayarındaki saatten alınmıştır. + Stick to a side: Bir kenara yapıştır: + Still rendering — this page is unusually complex… Hâlâ işleniyor — bu sayfa alışılmadık derecede karmaşık… + Success Başarılı + System Sistem + Tab Order Sekme Sırası + Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). Metin tanıma kurulu değil: modelleri eksik (`cargo xtask models` çalıştırın ya da PDFCRAFT_MODELS ayarlayın). + The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway. ECDSA imzası kurallı DER biçiminde değil (sondaki baytlar, dolgu ya da eksik işaret baytı); yine de okundu. + The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway. ECDSA imzası bir DER dizisi yerine ham r ve s değerlerinden oluşuyor; yine de okundu. + The RSA signature value does not have the length of the key's modulus (leading zero bytes dropped or added); it was read anyway. RSA imza değeri, anahtarın modülüyle aynı uzunlukta değil (baştaki sıfır baytlar atılmış ya da eklenmiş); yine de okundu. + The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. RSA imzasının DigestInfo alanında NULL parametresi yok (eski imzalayıcılar böyle yazar); yine de okundu. + The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. {} sertifikası altında bu kadar çok CA sertifikasına izin vermiyor (yol uzunluğu), bu yüzden {} için güvence olarak kullanılmıyor. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. {} sertifikasında PdfCraft'ın tanımadığı kritik uzantılar var, bu yüzden {} için güvence olarak kullanılmıyor. + The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. {} sertifikası, sertifika verebilen bir CA sertifikası değil, bu yüzden {} için güvence olarak kullanılmıyor. + The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. {} sertifikası imzalama sırasında geçerli değildi, bu yüzden {} için güvence olarak kullanılmıyor. + The checker found problems which may prevent the document from being fully accessible. Denetleyici, belgenin tam olarak erişilebilir olmasını engelleyebilecek sorunlar buldu. + The document has been altered or corrupted since the signature was applied. Belge, imza uygulandıktan sonra değiştirilmiş ya da bozulmuş. + The document has been altered or corrupted since the timestamp was applied. Belge, zaman damgası uygulandıktan sonra değiştirilmiş ya da bozulmuş. + The document has been altered since this signature was applied in ways it does not permit ({}). Belge, bu imza uygulandıktan sonra imzanın izin vermediği biçimlerde değiştirilmiş ({}). + The document has been altered since this timestamp was applied in ways it does not permit ({}). Belge, bu zaman damgası uygulandıktan sonra damganın izin vermediği biçimlerde değiştirilmiş ({}). + The document has been modified since this signature was applied, but the changes are permitted ({}). Belge, bu imza uygulandıktan sonra değiştirilmiş ancak değişikliklere izin veriliyor ({}). + The document has been modified since this timestamp was applied, but the changes are permitted ({}). Belge, bu zaman damgası uygulandıktan sonra değiştirilmiş ancak değişikliklere izin veriliyor ({}). + The document has no tags (structure tree) Belgede etiket (yapı ağacı) yok + The document has no title Belgenin başlığı yok + The document isn't marked as tagged (MarkInfo Marked) Belge etiketli olarak işaretlenmemiş (MarkInfo Marked) + The document isn't tagged, so none of its content is Belge etiketli değil, dolayısıyla içeriğinin hiçbir kısmı etiketli değil + The document's embedded revocation information shows that the signer's certificate has not been revoked. Belgeye gömülü iptal bilgileri, imzalayanın sertifikasının iptal edilmediğini gösteriyor. + The embedded timestamp token is valid and its authority is trusted; trusted time is {}. Gömülü zaman damgası belirteci geçerli ve yetkilisine güveniliyor; güvenilir zaman {}. + The font {} isn't embedded {} yazı tipi gömülü değil + The metadata doesn't identify the file as {} Meta veriler dosyayı {} olarak tanımlamıyor + The password is incorrect. Parola yanlış. + The password must have at least 6 characters. Parola en az 6 karakter olmalıdır. + The password must have at least 8 characters. Parola en az 8 karakter olmalıdır. + The passwords do not match. Parolalar eşleşmiyor. + The security settings don't allow screen readers to read the text Güvenlik ayarları ekran okuyucuların metni okumasına izin vermiyor + The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's. İmza algoritması ile SignerInfo'nun özet algoritması farklı karma işlevleri belirtiyor; imza SignerInfo'dakiyle oluşturulmuş. + The signature carries an unverified timestamp ({}): the timestamp authority is not in your list of trusted certificates, so the signing time claimed by the signer is used instead. İmza doğrulanmamış bir zaman damgası taşıyor ({}): zaman damgası yetkilisi güvenilen sertifikalar listenizde değil, bu yüzden onun yerine imzalayanın bildirdiği imzalama zamanı kullanılıyor. + The signature contents are not hexadecimal. İmza içeriği onaltılık değil. + The signature could not be checked ({}). İmza denetlenemedi ({}). + The signature could not be read ({}). İmza okunamadı ({}). + The signature could not be verified ({}). İmza doğrulanamadı ({}). + The signature has no valid byte range. İmzanın geçerli bir bayt aralığı yok. + The signature includes an embedded timestamp. İmza gömülü bir zaman damgası içeriyor. + The signature is BER-encoded (indefinite lengths) rather than DER; it was read leniently. İmza DER yerine BER ile kodlanmış (belirsiz uzunluklar); esnek biçimde okundu. + The signature uses the legacy adbe.x509.rsa_sha1 format. İmza eski adbe.x509.rsa_sha1 biçimini kullanıyor. + The signature value does not match the signer's certificate: the signature is corrupt. İmza değeri imzalayanın sertifikasıyla eşleşmiyor: imza bozuk. + The signature's byte range does not exclude exactly its contents. İmzanın bayt aralığı tam olarak kendi içeriğini dışarıda bırakmıyor. + The signature's byte range does not match the file. İmzanın bayt aralığı dosyayla eşleşmiyor. + The signature's signed attributes have no content type, which CMS requires; it was read anyway. İmzanın imzalı özniteliklerinde CMS'in gerektirdiği içerik türü yok; yine de okundu. + The signature's signed attributes have no message digest, so the signature does not cover the document. İmzanın imzalı özniteliklerinde ileti özeti yok, bu yüzden imza belgeyi kapsamıyor. + The signature's signed attributes name a content type other than data, so it is not a signature of the document. İmzanın imzalı öznitelikleri data dışında bir içerik türü belirtiyor, bu yüzden bu bir belge imzası değil. + The signature's timestamp token could not be verified ({}). İmzanın zaman damgası belirteci doğrulanamadı ({}). + The signature's timestamp token does not cover the signature value. İmzanın zaman damgası belirteci imza değerini kapsamıyor. + The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway. İmzalanan içerik, BER'in izin verdiği gibi oluşturulmuş (parçalı) bir OCTET STRING; yine de okundu. + The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation). İmzalayanın sertifikası dijital imzaya izin vermiyor (anahtar kullanımında ne dijital imza ne de inkâr edilemezlik var). + The signer's certificate has been revoked ({}). İmzalayanın sertifikası iptal edilmiş ({}). + The signer's certificate has critical extensions PdfCraft does not recognize ({}). İmzalayanın sertifikasında PdfCraft'ın tanımadığı kritik uzantılar var ({}). + The signer's certificate is not in the signature. İmzalayanın sertifikası imzada yok. + The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). İmzalayanın sertifikası belge imzalamak için verilmemiş (genişletilmiş anahtar kullanımında belge imzalama amacı yok). + The signer's certificate was not valid at the time of signing. İmzalayanın sertifikası imzalama sırasında geçerli değildi. + 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. İmzalayanın kimliği bilinmiyor; çünkü güvenilen sertifikalar listenize eklenmemiş ve üst sertifikalarının hiçbiri güvenilen sertifika değil. + The signer's identity is valid. İmzalayanın kimliği geçerli. + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Standart yazı tipleri “{c}” ({code}) karakterini çizemiyor. Şimdilik metin olarak yalnızca Batı Avrupa karakterleri eklenebilir. + The timestamp authority's certificate is not issued for time stamping. Zaman damgası yetkilisinin sertifikası zaman damgalama için verilmemiş. + The timestamp authority's certificate was not valid at the time of timestamping. Zaman damgası yetkilisinin sertifikası zaman damgalama sırasında geçerli değildi. + The timestamp contents are not hexadecimal. Zaman damgası içeriği onaltılık değil. + The timestamp has no valid byte range. Zaman damgasının geçerli bir bayt aralığı yok. + The timestamp token could not be read ({}). Zaman damgası belirteci okunamadı ({}). + The timestamp token is valid and its authority is trusted. Zaman damgası belirteci geçerli ve yetkilisine güveniliyor. + The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates. Zaman damgası belirteci geçerli ancak zaman damgası yetkilisi güvenilen sertifikalar listenizde değil. + The timestamp's byte range does not exclude exactly its contents. Zaman damgasının bayt aralığı tam olarak kendi içeriğini dışarıda bırakmıyor. + The timestamp's byte range does not match the file. Zaman damgasının bayt aralığı dosyayla eşleşmiyor. + The window shows the file name, not the title (Initial View > Show) Pencere başlığı değil dosya adını gösteriyor (İlk Görünüm > Göster) + Theme Tema + There is no folder to save the digital ID in. Dijital kimliğin kaydedileceği bir klasör yok. + This document has no text to make bookmarks from. Bu belgede yer işareti oluşturulacak metin yok. + This document has not been modified since this signature was applied. Bu belge, bu imza uygulandığından beri değiştirilmemiş. + This document has not been modified since this timestamp was applied. Bu belge, bu zaman damgası uygulandığından beri değiştirilmemiş. + This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Bu dosya diskte değişti. Yeni sürümü görmek için yeniden yükleyin; kaydedilmemiş değişiklikleriniz kaybolacak. + This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on. Bu imza, artık imzalar için önerilmeyen eski bir 160 bitlik karma algoritması olan RIPEMD-160'ı kullanıyor. Hâlâ doğrulanıyor ancak güvenilmemelidir. + This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on. Bu imza, zayıf bir karma algoritması olan SHA-1'i kullanıyor: bu algoritmada çakışmalar gösterilmiştir. Yıllar önce imzalanmış birçok belge onu kullandığı için hâlâ doğrulanıyor ancak güvenilmemelidir. + 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. Bu imza, artık imzalar için yeterince güçlü sayılmayan, 2048 bitten kısa bir RSA anahtarı kullanıyor. Hâlâ doğrulanıyor ancak güvenilmemelidir. + Toolbar size Araç çubuğu boyutu + Unsigned signature field İmzalanmamış imza alanı + Unsigned signature field: {name} İmzalanmamış imza alanı: {name} + Unspecified Belirtilmemiş + Use Column Order Sütun Sırasını Kullan + Use Document Structure Belge Yapısını Kullan + Use Row Order Satır Sırasını Kullan + Vertical layout Dikey düzen + Warning Uyarı + Window Pencere + Zoom in (⌘+) Yakınlaştır (⌘+) + Zoom in / out (also pinch or ⌘-scroll) Yakınlaştır / uzaklaştır (iki parmakla sıkıştırma ya da ⌘-kaydırma ile de) + Zoom out (⌘−) Uzaklaştır (⌘−) + a table has 1 to {max} columns bir tabloda 1 ile {max} arası sütun olur + a table has 1 to {max} rows bir tabloda 1 ile {max} arası satır olur + alternate text hides an annotation inside it alternatif metin içindeki bir açıklamayı gizliyor + alternate text on an element with no page content sayfa içeriği olmayan bir öğede alternatif metin + bold kalın + comments yorumlar + document security store belge güvenlik deposu + document structure belge yapısı + form fill form doldurma + italic italik + its alternate text is inside another element's and will never be read alternatif metni başka bir öğeninkinin içinde ve hiçbir zaman okunmayacak + links bağlantılar + metadata meta veriler + no alternate text alternatif metin yok + no header cells (TH) başlık hücresi (TH) yok + no summary özet yok + not in a TR bir TR içinde değil + not in a Table, THead, TBody or TFoot bir Table, THead, TBody ya da TFoot içinde değil + not in an L (list) bir L (liste) içinde değil + not in an LI (list item) bir LI (liste öğesi) içinde değil + of / + other changes diğer değişiklikler + page content sayfa içeriği + page {n} is out of range (1–{count}) {n}. sayfa aralığın dışında (1–{count}) + pages added or removed eklenen ya da kaldırılan sayfalar + rows have between {min} and {max} columns satırlarda {min} ile {max} arası sütun var + the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 alan, aralık ve sütun genişlikleri sayı olmalı; aralık 0 veya daha büyük, her genişlik 0'dan büyük olmalı + the first heading is an H{n}; headings should not skip levels ilk başlık bir H{n}; başlıklar düzey atlamamalı + the first row number can be at most {max} ilk satır numarası en fazla {max} olabilir + the signed version could not be read imzalı sürüm okunamadı + {font}, {size} pt {font}, {size} pt + {n} lines {n} satır + {n} pages and no bookmarks {n} sayfa ve hiç yer işareti yok + {page} is not a page number or label {page} bir sayfa numarası ya da etiketi değil + {ty} element on page {p}: {what} {p}. sayfadaki {ty} öğesi: {what} + {ty} element: {what} {ty} öğesi: {what} + {who}, certificate issued by an unknown issuer. {who}, sertifikayı bilinmeyen bir yayımcı vermiş. + {who}, certificate issued by {issuer}. {who}, sertifikayı {issuer} vermiş. + {} annotation on page {p} isn't tagged {p}. sayfadaki {} açıklaması etiketli değil + {} annotations aren't allowed {} açıklamalarına izin verilmiyor + … and {n} more … ve {n} tane daha +@plural Delete {n} field|Delete {n} fields {n} alanı sil +@plural {n} byte|{n} bytes {n} bayt +@plural {n} comment|{n} comments {n} yorum +@plural {n} file|{n} files {n} dosya +@plural {n} image optimized|{n} images optimized {n} resim iyileştirildi +@plural {n} item discarded|{n} items discarded {n} öğe atıldı +@plural {n} pixel|{n} pixels {n} piksel +@plural {n} result|{n} results {n} sonuç +calendar weekday Fr Cu +calendar weekday Mo Pt +calendar weekday Sa Ct +calendar weekday Su Pz +calendar weekday Th Pe +calendar weekday Tu Sa +calendar weekday We Ça diff --git a/docs/localization-parity.md b/docs/localization-parity.md index fee2ba3e7b..dc0cbab4e5 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,5 +1,6 @@ # Localization parity +> **Last reviewed:** 2026-11-03 · **Last updated:** 2026-11-03 · **Change:** minor (Turkish: 2,867 entries, 0 missing in `cargo xtask i18n-report` on 2026-11-03; AI-assisted) > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Persian (`fa`, 2,785 entries) added right-to-left like Arabic and Kurdish; Arabic current state corrected: catalogs stay logical, RTL labels take an explicit direction and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native release Hans face via LXGW WenKai Lite; release-pinned craft-fonts still has no Telugu face. Current union 2,798 keys. Scores and estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to @@ -72,7 +73,7 @@ help/docs polish (catalog and script work are already done), not missing UI stri | Polish | `pl` | 2,611 entries | partial | yes | | Slovenian | `sl` | 2,536 entries | partial | no | | Greek | `el` | 2,545 entries | partial | no | -| Turkish | `tr` | 2,525 entries | partial | yes | +| Turkish | `tr` | 2,867 entries (0 missing in `cargo xtask i18n-report`, 2026-11-03) | partial; AI-assisted, judgement calls reviewed by a native speaker, full proofreading pending | yes | | Lithuanian | `lt` | 2,798 (100% of current key union) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | | Finnish | `fi` | 2,798 entries (every current key) | partial; AI-assisted, native proofreading pending | yes | | Albanian | `sq` | 2,524 entries | partial; not yet reviewed by a native speaker | no | @@ -93,6 +94,7 @@ Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is | 2026-10-25 | minor | Added Persian (`fa`, 2,785 entries, all but 13 of the current 2,798-key union); right-to-left like Arabic and Kurdish, with logical entries reordered by egui 0.37's epaint; AI-assisted, not yet native-reviewed | | 2026-10-21 | minor | Brazilian Portuguese (`pt-br`) now covers all 2,674 distinct keys in the current catalog union; no help or engine/OS error-detail localization; twelve-language mean still rounds to ≈ 68% | | 2026-10-20 | minor | Spanish: the 143 keys the other catalogs had and `es.tsv` was missing; `es` now covers the whole 2,674-key union, like `lt` and `vi`. Restated Lithuanian against the current union (its 2,585 was measured on 2026-10-13) and said plainly in *How this was measured* that the union moves, so counts only compare within a date | +| 2026-11-03 | minor | Turkish: 2,867 entries after translating the 288 keys the translation gaps report listed (AI-assisted); measured with the report, not against the 2,798-key union of 2026-10-25, which predates keys added since | | 2026-10-19 | minor | Removed the 2026-10-13 status line and the 14-language summary that concurrent merges left beside the current ones; the summary keeps the Italian and Norwegian Bokmål measurement dates | | 2026-10-16 | minor | Czech: 2,674 entries after adding the keys the other catalogs had (the engine's sentences, weekday heads, certificate fields, shared buttons) and unifying "tag" as *tag* (not *značka*, which is a redaction mark) | | 2026-10-13 | minor | Vietnamese (`vi`) catalog: 2,591 entries (100% of current key union + `theme`/`Appearance`); twelve-language mean ≈ 68%; native-speaker community review; registered with `plural_none` |